mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b43f77d353 | ||
|
|
5dadb9a998 | ||
|
|
164c372b21 | ||
|
|
cb260fa3d8 | ||
|
|
05a2f56945 | ||
|
|
82a5d138e0 | ||
|
|
3d4f37c482 | ||
|
|
b03ad6d523 | ||
|
|
5c2b1be581 | ||
|
|
b3b2fe3ba2 | ||
|
|
9667ec2434 | ||
|
|
3c4c78d086 | ||
|
|
98aab7b34d | ||
|
|
81e828a200 | ||
|
|
218d25ba1b | ||
|
|
908a74f69d | ||
|
|
8368a28ff5 | ||
|
|
abe21d51cb | ||
|
|
339ecc418d | ||
|
|
5ff7e2af24 | ||
|
|
9aa47c267d | ||
|
|
12dca64b60 | ||
|
|
952f48c72a | ||
|
|
4e80f7eb9a | ||
|
|
9db037722c | ||
|
|
ded05ebea2 | ||
|
|
a71f3555ae | ||
|
|
4500349fda | ||
|
|
611495ec28 | ||
|
|
347436d370 | ||
|
|
dd925e335b | ||
|
|
0e8b5f1d04 | ||
|
|
8dfaf4a9a9 | ||
|
|
d5b2f97252 | ||
|
|
47a81d165e | ||
|
|
d7c8b9d43a | ||
|
|
7562946434 | ||
|
|
4d19a5e855 | ||
|
|
1c85789a6e | ||
|
|
a76a42d2dc | ||
|
|
79d219bf50 | ||
|
|
9b2143a2a5 | ||
|
|
b174a50b2a | ||
|
|
76965252d8 | ||
|
|
3e0ee30428 | ||
|
|
0e7c50ef71 | ||
|
|
b8ee9fbe2c | ||
|
|
d49dea9790 | ||
|
|
8891890601 | ||
|
|
0272a7f337 | ||
|
|
52d5ae50d9 | ||
|
|
db85004b12 | ||
|
|
b7e4f78c56 | ||
|
|
159da8ba31 | ||
|
|
cfddef3706 | ||
|
|
ca8d613614 | ||
|
|
8c579cf88d | ||
|
|
a57d970840 | ||
|
|
88ce622e4a | ||
|
|
c041e57b8f | ||
|
|
5d6e43e01b | ||
|
|
4a0f00f7db | ||
|
|
de21cab22a | ||
|
|
f8929e1f2e | ||
|
|
754ddbef3c | ||
|
|
ed93b77faa | ||
|
|
5a6cfdbe4c | ||
|
|
6b90d00cc2 | ||
|
|
3ba5e12f89 | ||
|
|
dc441c3973 | ||
|
|
9745df4d37 | ||
|
|
715adf62ca | ||
|
|
90ab70dfc6 | ||
|
|
b0c5185ebd | ||
|
|
d58873ef14 | ||
|
|
105c885c3f | ||
|
|
ad625029e8 | ||
|
|
aeaa184aec | ||
|
|
9dd707cd1b | ||
|
|
9f0ca2bc53 | ||
|
|
605cf0c985 | ||
|
|
7e1838dda6 | ||
|
|
2016e9bc2f | ||
|
|
b3d1410ca9 | ||
|
|
a2d56a47cd | ||
|
|
316bbfe43d | ||
|
|
3b5351afb6 | ||
|
|
ecc4052bdc | ||
|
|
fc84012977 | ||
|
|
c882afbd2c | ||
|
|
305697a5d3 | ||
|
|
d954681132 | ||
|
|
872345366a | ||
|
|
5b1aec0f0b | ||
|
|
98d063132c | ||
|
|
fcb3699e99 | ||
|
|
39658a7ee1 | ||
|
|
6213df9fb3 | ||
|
|
30df7a43d5 | ||
|
|
8fc9fdb7dc | ||
|
|
77905e241f |
@@ -26,7 +26,7 @@ $console = [
|
||||
'hostname' => 'localhost',
|
||||
], // Current host is added on app init
|
||||
],
|
||||
'region' => 'fra',
|
||||
'region' => System::getEnv('_APP_REGION', 'default'),
|
||||
'legalName' => '',
|
||||
'legalCountry' => '',
|
||||
'legalState' => '',
|
||||
|
||||
@@ -630,6 +630,11 @@ return [
|
||||
'description' => 'Site with the requested ID could not be found.',
|
||||
'code' => 404,
|
||||
],
|
||||
Exception::SITE_ALREADY_EXISTS => [
|
||||
'name' => Exception::SITE_ALREADY_EXISTS,
|
||||
'description' => 'Site with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
|
||||
'code' => 409,
|
||||
],
|
||||
Exception::SITE_TEMPLATE_NOT_FOUND => [
|
||||
'name' => Exception::SITE_TEMPLATE_NOT_FOUND,
|
||||
'description' => 'Site Template with the requested ID could not be found.',
|
||||
@@ -1291,6 +1296,11 @@ return [
|
||||
'description' => 'Message with the requested ID could not be found.',
|
||||
'code' => 404,
|
||||
],
|
||||
Exception::MESSAGE_ALREADY_EXISTS => [
|
||||
'name' => Exception::MESSAGE_ALREADY_EXISTS,
|
||||
'description' => 'Message with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
|
||||
'code' => 409,
|
||||
],
|
||||
Exception::MESSAGE_MISSING_TARGET => [
|
||||
'name' => Exception::MESSAGE_MISSING_TARGET,
|
||||
'description' => 'Message with the requested ID has no recipients (topics or users or targets).',
|
||||
|
||||
@@ -160,7 +160,7 @@ return [
|
||||
'name' => 'Teams',
|
||||
'subtitle' => 'The Teams service allows you to group users of your project and to enable them to share read and write access to your project resources',
|
||||
'description' => '/docs/services/teams.md',
|
||||
'controller' => 'api/teams.php',
|
||||
'controller' => '', // Uses modules
|
||||
'sdk' => true,
|
||||
'docs' => true,
|
||||
'docsUrl' => 'https://appwrite.io/docs/client/teams',
|
||||
|
||||
@@ -3251,7 +3251,7 @@ Http::post('/v1/messaging/messages/email')
|
||||
}
|
||||
}
|
||||
|
||||
$message = $dbForProject->createDocument('messages', new Document([
|
||||
$message = new Document([
|
||||
'$id' => $messageId,
|
||||
'providerType' => MESSAGE_TYPE_EMAIL,
|
||||
'topics' => $topics,
|
||||
@@ -3267,7 +3267,12 @@ Http::post('/v1/messaging/messages/email')
|
||||
'attachments' => $attachments,
|
||||
],
|
||||
'status' => $status,
|
||||
]));
|
||||
]);
|
||||
try {
|
||||
$message = $dbForProject->createDocument('messages', $message);
|
||||
} catch (DuplicateException) {
|
||||
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case MessageStatus::PROCESSING:
|
||||
@@ -3400,7 +3405,7 @@ Http::post('/v1/messaging/messages/sms')
|
||||
}
|
||||
}
|
||||
|
||||
$message = $dbForProject->createDocument('messages', new Document([
|
||||
$message = new Document([
|
||||
'$id' => $messageId,
|
||||
'providerType' => MESSAGE_TYPE_SMS,
|
||||
'topics' => $topics,
|
||||
@@ -3410,7 +3415,12 @@ Http::post('/v1/messaging/messages/sms')
|
||||
'content' => $content,
|
||||
],
|
||||
'status' => $status,
|
||||
]));
|
||||
]);
|
||||
try {
|
||||
$message = $dbForProject->createDocument('messages', $message);
|
||||
} catch (DuplicateException) {
|
||||
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case MessageStatus::PROCESSING:
|
||||
@@ -3620,7 +3630,7 @@ Http::post('/v1/messaging/messages/push')
|
||||
$pushData['priority'] = $priority;
|
||||
}
|
||||
|
||||
$message = $dbForProject->createDocument('messages', new Document([
|
||||
$message = new Document([
|
||||
'$id' => $messageId,
|
||||
'providerType' => MESSAGE_TYPE_PUSH,
|
||||
'topics' => $topics,
|
||||
@@ -3629,7 +3639,12 @@ Http::post('/v1/messaging/messages/push')
|
||||
'scheduledAt' => $scheduledAt,
|
||||
'data' => $pushData,
|
||||
'status' => $status,
|
||||
]));
|
||||
]);
|
||||
try {
|
||||
$message = $dbForProject->createDocument('messages', $message);
|
||||
} catch (DuplicateException) {
|
||||
throw new Exception(Exception::MESSAGE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case MessageStatus::PROCESSING:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,8 +73,9 @@ use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
/** TODO: Remove function when we move to using utopia/platform */
|
||||
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
|
||||
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, ?string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
|
||||
{
|
||||
$name = $name ?? '';
|
||||
$plaintextPassword = $password;
|
||||
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
|
||||
|
||||
@@ -255,7 +256,7 @@ Http::post('/v1/users')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$plaintext = new Plaintext();
|
||||
|
||||
$user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
|
||||
@@ -291,7 +292,7 @@ Http::post('/v1/users/bcrypt')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$bcrypt = new Bcrypt();
|
||||
$bcrypt->setCost(8); // Default cost
|
||||
|
||||
@@ -329,7 +330,7 @@ Http::post('/v1/users/md5')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$md5 = new MD5();
|
||||
|
||||
$user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
|
||||
@@ -366,7 +367,7 @@ Http::post('/v1/users/argon2')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$argon2 = new Argon2();
|
||||
|
||||
$user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
|
||||
@@ -404,7 +405,7 @@ Http::post('/v1/users/sha')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, string $passwordVersion, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$sha = new Sha();
|
||||
if (!empty($passwordVersion)) {
|
||||
$sha->setVersion($passwordVersion);
|
||||
@@ -444,7 +445,7 @@ Http::post('/v1/users/phpass')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$phpass = new PHPass();
|
||||
|
||||
$user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
|
||||
@@ -486,7 +487,7 @@ Http::post('/v1/users/scrypt')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$scrypt = new Scrypt();
|
||||
$scrypt
|
||||
->setSalt($passwordSalt)
|
||||
@@ -532,7 +533,7 @@ Http::post('/v1/users/scrypt-modified')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('hooks')
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, ?string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
|
||||
$scryptModified = new ScryptModified();
|
||||
$scryptModified
|
||||
->setSalt($passwordSalt)
|
||||
|
||||
@@ -27,6 +27,7 @@ use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Duplicate as DuplicateException;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Authorization\Input;
|
||||
@@ -929,14 +930,19 @@ Http::shutdown()
|
||||
$accessedAt = $cacheLog->getAttribute('accessedAt', 0);
|
||||
$now = DateTime::now();
|
||||
if ($cacheLog->isEmpty()) {
|
||||
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
|
||||
'$id' => $key,
|
||||
'resource' => $resource,
|
||||
'resourceType' => $resourceType,
|
||||
'mimeType' => $response->getContentType(),
|
||||
'accessedAt' => $now,
|
||||
'signature' => $signature,
|
||||
])));
|
||||
try {
|
||||
$authorization->skip(fn () => $dbForProject->createDocument('cache', new Document([
|
||||
'$id' => $key,
|
||||
'resource' => $resource,
|
||||
'resourceType' => $resourceType,
|
||||
'mimeType' => $response->getContentType(),
|
||||
'accessedAt' => $now,
|
||||
'signature' => $signature,
|
||||
])));
|
||||
} catch (DuplicateException) {
|
||||
// Race condition: another concurrent request already created the cache document
|
||||
$cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key));
|
||||
}
|
||||
} elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
|
||||
$cacheLog->setAttribute('accessedAt', $now);
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), $cacheLog));
|
||||
|
||||
@@ -6,6 +6,7 @@ const APP_NAME = 'Appwrite';
|
||||
const APP_DOMAIN = 'appwrite.io';
|
||||
|
||||
const APP_VIEWS_DIR = __DIR__ . '/../views';
|
||||
const APP_CE_CONFIG_DIR = __DIR__ . '/../config';
|
||||
|
||||
// Email
|
||||
const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address
|
||||
|
||||
@@ -88,6 +88,10 @@ Database::addFilter(
|
||||
break;
|
||||
|
||||
case Database::VAR_STRING:
|
||||
case Database::VAR_VARCHAR:
|
||||
case Database::VAR_TEXT:
|
||||
case Database::VAR_MEDIUMTEXT:
|
||||
case Database::VAR_LONGTEXT:
|
||||
$filters = $attribute->getAttribute('filters', []);
|
||||
$attribute->setAttribute('encrypt', in_array('encrypt', $filters));
|
||||
break;
|
||||
|
||||
+69
-41
@@ -248,48 +248,62 @@ $adapter
|
||||
|
||||
$server = new Server($adapter);
|
||||
|
||||
$logError = function (Throwable $error, string $action) use ($register) {
|
||||
$logger = $register->get('realtimeLogger');
|
||||
// Allows overriding
|
||||
if (!function_exists('logError')) {
|
||||
function logError(Throwable $error, string $action, array $tags = [], ?Document $project = null, ?Document $user = null, ?Authorization $authorization = null): void
|
||||
{
|
||||
global $register;
|
||||
|
||||
if ($logger && !$error instanceof Exception) {
|
||||
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
|
||||
$logger = $register->get('realtimeLogger');
|
||||
|
||||
$log = new Log();
|
||||
$log->setNamespace("realtime");
|
||||
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
|
||||
$log->setVersion($version);
|
||||
$log->setType(Log::TYPE_ERROR);
|
||||
$log->setMessage($error->getMessage());
|
||||
if ($logger && !$error instanceof Exception) {
|
||||
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
|
||||
|
||||
$log->addTag('code', $error->getCode());
|
||||
$log->addTag('verboseType', get_class($error));
|
||||
$log = new Log();
|
||||
$log->setNamespace("realtime");
|
||||
$log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
|
||||
$log->setVersion($version);
|
||||
$log->setType(Log::TYPE_ERROR);
|
||||
$log->setMessage($error->getMessage());
|
||||
|
||||
$log->addExtra('file', $error->getFile());
|
||||
$log->addExtra('line', $error->getLine());
|
||||
$log->addExtra('trace', $error->getTraceAsString());
|
||||
$log->addTag('code', $error->getCode());
|
||||
$log->addTag('verboseType', get_class($error));
|
||||
$log->addTag('projectId', $project?->getId() ?: 'n/a');
|
||||
$log->addTag('userId', $user?->getId() ?: 'n/a');
|
||||
|
||||
$log->setAction($action);
|
||||
foreach ($tags as $key => $value) {
|
||||
$log->addTag($key, $value ?: 'n/a');
|
||||
}
|
||||
|
||||
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
|
||||
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
|
||||
$log->addExtra('file', $error->getFile());
|
||||
$log->addExtra('line', $error->getLine());
|
||||
$log->addExtra('trace', $error->getTraceAsString());
|
||||
$log->addExtra('detailedTrace', $error->getTrace());
|
||||
$log->addExtra('roles', $authorization?->getRoles() ?? []);
|
||||
|
||||
try {
|
||||
$responseCode = $logger->addLog($log);
|
||||
Console::info('Error log pushed with status code: ' . $responseCode);
|
||||
} catch (Throwable $th) {
|
||||
Console::error('Error pushing log: ' . $th->getMessage());
|
||||
$log->setAction($action);
|
||||
|
||||
$isProduction = System::getEnv('_APP_ENV', 'development') === 'production';
|
||||
$log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING);
|
||||
|
||||
try {
|
||||
$responseCode = $logger->addLog($log);
|
||||
Console::info('Error log pushed with status code: ' . $responseCode);
|
||||
} catch (Throwable $th) {
|
||||
Console::error('Error pushing log: ' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Console::error('[Error] Type: ' . get_class($error));
|
||||
Console::error('[Error] Message: ' . $error->getMessage());
|
||||
Console::error('[Error] File: ' . $error->getFile());
|
||||
Console::error('[Error] Line: ' . $error->getLine());
|
||||
}
|
||||
}
|
||||
|
||||
Console::error('[Error] Type: ' . get_class($error));
|
||||
Console::error('[Error] Message: ' . $error->getMessage());
|
||||
Console::error('[Error] File: ' . $error->getFile());
|
||||
Console::error('[Error] Line: ' . $error->getLine());
|
||||
};
|
||||
$server->error(logError(...));
|
||||
|
||||
$server->error($logError);
|
||||
|
||||
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument, $logError) {
|
||||
$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) {
|
||||
sleep(5); // wait for the initial database schema to be ready
|
||||
Console::success('Server started successfully');
|
||||
|
||||
@@ -326,7 +340,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
|
||||
*/
|
||||
// TODO: Remove this if check once it doesn't cause issues for cloud
|
||||
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
|
||||
Timer::tick(5000, function () use ($register, $stats, &$statsDocument, $logError) {
|
||||
Timer::tick(5000, function () use ($register, $stats, &$statsDocument) {
|
||||
$payload = [];
|
||||
foreach ($stats as $projectId => $value) {
|
||||
$payload[$projectId] = $stats->get($projectId, 'connectionsTotal');
|
||||
@@ -344,13 +358,13 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume
|
||||
|
||||
$database->getAuthorization()->skip(fn () => $database->updateDocument('realtime', $statsDocument->getId(), $statsDocument));
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "updateWorkerDocument");
|
||||
logError($th, "updateWorkerDocument");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime, $logError) {
|
||||
$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) {
|
||||
Console::success('Worker ' . $workerId . ' started successfully');
|
||||
|
||||
$telemetry = getTelemetry($workerId);
|
||||
@@ -362,7 +376,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
|
||||
$attempts = 0;
|
||||
$start = time();
|
||||
|
||||
Timer::tick(5000, function () use ($server, $register, $realtime, $stats, $logError) {
|
||||
Timer::tick(5000, function () use ($server, $register, $realtime, $stats) {
|
||||
/**
|
||||
* Sending current connections to project channels on the console project every 5 seconds.
|
||||
*/
|
||||
@@ -548,7 +562,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
|
||||
}
|
||||
});
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "pubSubConnection");
|
||||
logError($th, "pubSubConnection");
|
||||
|
||||
Console::error('Pub/sub error: ' . $th->getMessage());
|
||||
$attempts++;
|
||||
@@ -560,7 +574,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
|
||||
Console::error('Failed to restart pub/sub...');
|
||||
});
|
||||
|
||||
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $logError) {
|
||||
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) {
|
||||
$app = new Http('UTC');
|
||||
$request = new Request($request);
|
||||
$response = new Response(new SwooleResponse());
|
||||
@@ -571,6 +585,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
Http::setResource('request', fn () => $request);
|
||||
Http::setResource('response', fn () => $response);
|
||||
|
||||
$project = null;
|
||||
$logUser = null;
|
||||
$authorization = null;
|
||||
|
||||
try {
|
||||
/** @var Document $project */
|
||||
$project = $app->getResource('project');
|
||||
@@ -591,8 +609,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
|
||||
}
|
||||
|
||||
$projectRegion = $project->getAttribute('region', '');
|
||||
$currentRegion = System::getEnv('_APP_REGION', 'default');
|
||||
if (!empty($projectRegion) && $projectRegion !== $currentRegion) {
|
||||
throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint');
|
||||
}
|
||||
|
||||
$timelimit = $app->getResource('timelimit');
|
||||
$user = $app->getResource('user'); /** @var User $user */
|
||||
$logUser = $user;
|
||||
|
||||
/*
|
||||
* Abuse Check
|
||||
@@ -683,11 +708,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
$stats->incr($project->getId(), 'connections');
|
||||
$stats->incr($project->getId(), 'connectionsTotal');
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "initServer");
|
||||
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
|
||||
|
||||
// Handle SQL error code is 'HY000'
|
||||
$code = $th->getCode();
|
||||
if (!is_int($code)) {
|
||||
if (!\is_int($code)) {
|
||||
$code = 500;
|
||||
}
|
||||
|
||||
@@ -718,7 +743,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
}
|
||||
});
|
||||
|
||||
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId, $logError) {
|
||||
$server->onMessage(function (int $connection, string $message) use ($server, $register, $realtime, $containerId) {
|
||||
$project = null;
|
||||
$authorization = null;
|
||||
|
||||
try {
|
||||
$response = new Response(new SwooleResponse());
|
||||
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
|
||||
@@ -844,7 +872,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
|
||||
}
|
||||
} catch (Throwable $th) {
|
||||
$logError($th, "realtimeMessage");
|
||||
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
|
||||
$code = $th->getCode();
|
||||
if (!is_int($code)) {
|
||||
$code = 500;
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
"utopia-php/storage": "1.0.*",
|
||||
"utopia-php/system": "0.10.*",
|
||||
"utopia-php/telemetry": "0.2.*",
|
||||
"utopia-php/vcs": "1.*",
|
||||
"utopia-php/vcs": "2.*",
|
||||
"utopia-php/websocket": "1.0.*",
|
||||
"matomo/device-detector": "6.4.*",
|
||||
"dragonmantank/cron-expression": "3.4.*",
|
||||
|
||||
Generated
+38
-38
@@ -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": "cb2ca42cfed8603c31b75113c85a4699",
|
||||
"content-hash": "1fb043a556550f62ab27a0aad0b9332a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "adhocore/jwt",
|
||||
@@ -4517,16 +4517,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/migration",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/migration.git",
|
||||
"reference": "c5c7544d02d2418536d41050794050132f247d62"
|
||||
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/migration/zipball/c5c7544d02d2418536d41050794050132f247d62",
|
||||
"reference": "c5c7544d02d2418536d41050794050132f247d62",
|
||||
"url": "https://api.github.com/repos/utopia-php/migration/zipball/037bf4b3813d44f1b0990bc124e35b501ed27fca",
|
||||
"reference": "037bf4b3813d44f1b0990bc124e35b501ed27fca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4566,9 +4566,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/migration/issues",
|
||||
"source": "https://github.com/utopia-php/migration/tree/1.6.1"
|
||||
"source": "https://github.com/utopia-php/migration/tree/1.6.2"
|
||||
},
|
||||
"time": "2026-02-17T05:49:48+00:00"
|
||||
"time": "2026-02-25T12:00:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/mongo",
|
||||
@@ -4684,16 +4684,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/pools",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/pools.git",
|
||||
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1"
|
||||
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/pools/zipball/b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
|
||||
"reference": "b7d8dd00306cdd8bf3ff6f1dc90caeaf27dabeb1",
|
||||
"url": "https://api.github.com/repos/utopia-php/pools/zipball/74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
|
||||
"reference": "74de7c5457a2c447f27e7ec4d72e8412a7d68c10",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4731,9 +4731,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/pools/issues",
|
||||
"source": "https://github.com/utopia-php/pools/tree/1.0.2"
|
||||
"source": "https://github.com/utopia-php/pools/tree/1.0.3"
|
||||
},
|
||||
"time": "2026-01-28T13:12:36+00:00"
|
||||
"time": "2026-02-26T08:42:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/preloader",
|
||||
@@ -5215,16 +5215,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/vcs",
|
||||
"version": "1.0.1",
|
||||
"version": "2.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/vcs.git",
|
||||
"reference": "0f942f667319bb22cd420431e4e79107b41061f3"
|
||||
"reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/0f942f667319bb22cd420431e4e79107b41061f3",
|
||||
"reference": "0f942f667319bb22cd420431e4e79107b41061f3",
|
||||
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/92a1650824ba0c5e6a1bc46e622ac87c50a08920",
|
||||
"reference": "92a1650824ba0c5e6a1bc46e622ac87c50a08920",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5258,9 +5258,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/vcs/issues",
|
||||
"source": "https://github.com/utopia-php/vcs/tree/1.0.1"
|
||||
"source": "https://github.com/utopia-php/vcs/tree/2.0.1"
|
||||
},
|
||||
"time": "2026-02-06T10:11:58+00:00"
|
||||
"time": "2026-02-27T12:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/websocket",
|
||||
@@ -5438,16 +5438,16 @@
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "appwrite/sdk-generator",
|
||||
"version": "1.11.1",
|
||||
"version": "1.11.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/appwrite/sdk-generator.git",
|
||||
"reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb"
|
||||
"reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6ff411f26f2750eea05c7598c14bb3a2ada898cb",
|
||||
"reference": "6ff411f26f2750eea05c7598c14bb3a2ada898cb",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/45d22c0107a53bb9a0a4e39db0e738d461631d11",
|
||||
"reference": "45d22c0107a53bb9a0a4e39db0e738d461631d11",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5483,22 +5483,22 @@
|
||||
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
|
||||
"support": {
|
||||
"issues": "https://github.com/appwrite/sdk-generator/issues",
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/1.11.1"
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/1.11.3"
|
||||
},
|
||||
"time": "2026-02-25T07:15:19+00:00"
|
||||
"time": "2026-02-27T06:54:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brianium/paratest",
|
||||
"version": "v7.19.0",
|
||||
"version": "v7.19.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paratestphp/paratest.git",
|
||||
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6"
|
||||
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
|
||||
"reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6",
|
||||
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/95b03194f4cdf5c83175ceead673e21cb66465e7",
|
||||
"reference": "95b03194f4cdf5c83175ceead673e21cb66465e7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5512,7 +5512,7 @@
|
||||
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
|
||||
"phpunit/php-file-iterator": "^6.0.1 || ^7",
|
||||
"phpunit/php-timer": "^8 || ^9",
|
||||
"phpunit/phpunit": "^12.5.9 || ^13",
|
||||
"phpunit/phpunit": "^12.5.14 || ^13.0.5",
|
||||
"sebastian/environment": "^8.0.3 || ^9",
|
||||
"symfony/console": "^7.4.4 || ^8.0.4",
|
||||
"symfony/process": "^7.4.5 || ^8.0.5"
|
||||
@@ -5522,10 +5522,10 @@
|
||||
"ext-pcntl": "*",
|
||||
"ext-pcov": "*",
|
||||
"ext-posix": "*",
|
||||
"phpstan/phpstan": "^2.1.38",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.3",
|
||||
"phpstan/phpstan-phpunit": "^2.0.12",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.8",
|
||||
"phpstan/phpstan": "^2.1.40",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.4",
|
||||
"phpstan/phpstan-phpunit": "^2.0.16",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.10",
|
||||
"symfony/filesystem": "^7.4.0 || ^8.0.1"
|
||||
},
|
||||
"bin": [
|
||||
@@ -5566,7 +5566,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/paratestphp/paratest/issues",
|
||||
"source": "https://github.com/paratestphp/paratest/tree/v7.19.0"
|
||||
"source": "https://github.com/paratestphp/paratest/tree/v7.19.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5578,7 +5578,7 @@
|
||||
"type": "paypal"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-06T10:53:26+00:00"
|
||||
"time": "2026-02-25T14:53:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/annotations",
|
||||
@@ -9043,7 +9043,7 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "dev",
|
||||
"stability-flags": {},
|
||||
"stability-flags": [],
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
@@ -9067,5 +9067,5 @@
|
||||
"platform-overrides": {
|
||||
"php": "8.3"
|
||||
},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -518,10 +518,11 @@ class Event
|
||||
*
|
||||
* @param string $pattern
|
||||
* @param array $params
|
||||
* @param ?Document $database
|
||||
* @return array
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public static function generateEvents(string $pattern, array $params = []): array
|
||||
public static function generateEvents(string $pattern, array $params = [], ?Document $database = null): array
|
||||
{
|
||||
// $params = \array_filter($params, fn($param) => !\is_array($param));
|
||||
$paramKeys = \array_keys($params);
|
||||
@@ -530,6 +531,12 @@ class Event
|
||||
$patterns = [];
|
||||
|
||||
$parsed = self::parseEventPattern($pattern);
|
||||
// to switch the resource types from databases to the required prefix
|
||||
// eg; all databases events get fired with databases. prefix which mainly depicts legacy type
|
||||
// so a projection from databases to the actual prefix
|
||||
if ((str_contains($pattern, 'databases.') && $database && $database->getAttribute('type') !== 'legacy')) {
|
||||
$parsed = self::getDatabaseTypeEvents($database, $parsed);
|
||||
}
|
||||
$type = $parsed['type'];
|
||||
$resource = $parsed['resource'];
|
||||
$subType = $parsed['subType'];
|
||||
@@ -630,8 +637,8 @@ class Event
|
||||
$eventValues = \array_values($events);
|
||||
|
||||
/**
|
||||
* Return a combined list of table, collection events.
|
||||
*/
|
||||
* Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility
|
||||
*/
|
||||
return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues);
|
||||
}
|
||||
|
||||
@@ -671,16 +678,41 @@ class Event
|
||||
'attributes' => 'columns',
|
||||
];
|
||||
|
||||
$databasesEventMap = [
|
||||
'tablesdb' => 'databases',
|
||||
'tables' => 'collections',
|
||||
'rows' => 'documents',
|
||||
'columns' => 'attributes'
|
||||
];
|
||||
|
||||
if (
|
||||
str_contains($pattern, 'databases.') &&
|
||||
str_contains($firstEvent, 'collections')
|
||||
(
|
||||
str_contains($pattern, 'databases.') &&
|
||||
str_contains($firstEvent, 'collections')
|
||||
) ||
|
||||
(
|
||||
str_contains($firstEvent, 'tablesdb.')
|
||||
)
|
||||
) {
|
||||
$pairedEvents = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$pairedEvents[] = $event;
|
||||
|
||||
if (str_contains($event, 'collections')) {
|
||||
// tablesdb needs databases event with tables and collections
|
||||
if (str_contains($event, 'tablesdb')) {
|
||||
$databasesSideEvent = str_replace(
|
||||
array_keys($databasesEventMap),
|
||||
array_values($databasesEventMap),
|
||||
$event
|
||||
);
|
||||
$pairedEvents[] = $databasesSideEvent;
|
||||
$tableSideEvent = str_replace(
|
||||
array_keys($tableEventMap),
|
||||
array_values($tableEventMap),
|
||||
$databasesSideEvent
|
||||
);
|
||||
$pairedEvents[] = $tableSideEvent;
|
||||
} elseif (str_contains($event, 'collections')) {
|
||||
$tableSideEvent = str_replace(
|
||||
array_keys($tableEventMap),
|
||||
array_values($tableEventMap),
|
||||
@@ -692,8 +724,34 @@ class Event
|
||||
|
||||
$events = $pairedEvents;
|
||||
}
|
||||
// mirrored events can have duplicates in case of smaller events
|
||||
// array unique can turns list to hasmap in case duplicates present
|
||||
// so forcing array value will turn this to array list always
|
||||
return array_values(array_unique($events));
|
||||
}
|
||||
|
||||
return $events;
|
||||
/**
|
||||
* Maps event terminology based on database type
|
||||
*/
|
||||
private static function getDatabaseTypeEvents(Document $database, array $event): array
|
||||
{
|
||||
$eventMap = [];
|
||||
switch ($database->getAttribute('type')) {
|
||||
case 'tablesdb':
|
||||
$eventMap = [
|
||||
'databases' => 'tablesdb',
|
||||
'documents' => 'rows',
|
||||
'collections' => 'tables',
|
||||
'attributes' => 'columns',
|
||||
];
|
||||
break;
|
||||
}
|
||||
foreach ($event as $eventKey => $eventValue) {
|
||||
if (isset($eventMap[$eventValue])) {
|
||||
$event[$eventKey] = $eventMap[$eventValue];
|
||||
}
|
||||
}
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,7 +72,7 @@ class Realtime extends Event
|
||||
return false;
|
||||
}
|
||||
|
||||
$allEvents = Event::generateEvents($this->getEvent(), $this->getParams());
|
||||
$allEvents = Event::generateEvents($this->getEvent(), $this->getParams(), $this->getContext('database'));
|
||||
|
||||
$payload = new Document($this->getPayload());
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ class Exception extends \Exception
|
||||
|
||||
/** Sites */
|
||||
public const string SITE_NOT_FOUND = 'site_not_found';
|
||||
public const string SITE_ALREADY_EXISTS = 'site_already_exists';
|
||||
public const string SITE_TEMPLATE_NOT_FOUND = 'site_template_not_found';
|
||||
|
||||
/** Functions */
|
||||
@@ -365,6 +366,7 @@ class Exception extends \Exception
|
||||
|
||||
/** Message */
|
||||
public const string MESSAGE_NOT_FOUND = 'message_not_found';
|
||||
public const string MESSAGE_ALREADY_EXISTS = 'message_already_exists';
|
||||
public const string MESSAGE_MISSING_TARGET = 'message_missing_target';
|
||||
public const string MESSAGE_ALREADY_SENT = 'message_already_sent';
|
||||
public const string MESSAGE_ALREADY_PROCESSING = 'message_already_processing';
|
||||
|
||||
@@ -491,6 +491,7 @@ class Realtime extends MessagingAdapter
|
||||
$roles = [Role::team(ID::custom($parts[1]))->toString()];
|
||||
break;
|
||||
case 'databases':
|
||||
case 'tablesdb':
|
||||
$resource = $parts[4] ?? '';
|
||||
if (in_array($resource, ['columns', 'attributes', 'indexes'])) {
|
||||
$channels[] = 'console';
|
||||
@@ -508,14 +509,14 @@ class Realtime extends MessagingAdapter
|
||||
$tableId = $payload->getAttribute('$tableId', '');
|
||||
$collectionId = $payload->getAttribute('$collectionId', '');
|
||||
$resourceId = $tableId ?: $collectionId;
|
||||
$channels = [];
|
||||
|
||||
$channels[] = 'rows';
|
||||
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows';
|
||||
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows.' . $payload->getId();
|
||||
|
||||
$channels[] = 'documents';
|
||||
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents';
|
||||
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents.' . $payload->getId();
|
||||
// sending legacy + tablesdb events to both legacy and tablesdb
|
||||
$channels = array_values(array_unique(array_merge(
|
||||
self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'),
|
||||
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'),
|
||||
self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId())
|
||||
)));
|
||||
|
||||
$roles = $collection->getAttribute('documentSecurity', false)
|
||||
? \array_merge($collection->getRead(), $payload->getRead())
|
||||
@@ -572,4 +573,56 @@ class Realtime extends MessagingAdapter
|
||||
'projectId' => $projectId
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate realtime channels for database events
|
||||
*
|
||||
* @param string $type The database API type
|
||||
* @param string $databaseId The database ID
|
||||
* @param string $resourceId The collection/table ID
|
||||
* @param string $payloadId The document/row ID
|
||||
* @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes
|
||||
* @return array Array of channel names
|
||||
*/
|
||||
private static function getDatabaseChannels(
|
||||
string $type = 'databases',
|
||||
string $databaseId = '',
|
||||
string $resourceId = '',
|
||||
string $payloadId = '',
|
||||
string $prefixOverride = '',
|
||||
): array {
|
||||
$basePrefix = $prefixOverride ?: $type;
|
||||
|
||||
if (!$databaseId || !$resourceId || !$payloadId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$channels = [];
|
||||
|
||||
switch ($type) {
|
||||
case 'legacy':
|
||||
if (empty($prefixOverride)) {
|
||||
$basePrefix = 'databases';
|
||||
}
|
||||
$channels[] = 'documents';
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
|
||||
break;
|
||||
|
||||
case 'tablesdb':
|
||||
$channels[] = 'rows';
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows";
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows.{$payloadId}";
|
||||
break;
|
||||
|
||||
default:
|
||||
$basePrefix = 'databases';
|
||||
$channels[] = 'documents';
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents";
|
||||
$channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}";
|
||||
break;
|
||||
|
||||
}
|
||||
return $channels;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use Appwrite\Platform\Modules\Projects;
|
||||
use Appwrite\Platform\Modules\Proxy;
|
||||
use Appwrite\Platform\Modules\Sites;
|
||||
use Appwrite\Platform\Modules\Storage;
|
||||
use Appwrite\Platform\Modules\Teams;
|
||||
use Appwrite\Platform\Modules\Tokens;
|
||||
use Appwrite\Platform\Modules\VCS;
|
||||
use Utopia\Platform\Platform;
|
||||
@@ -31,6 +32,7 @@ class Appwrite extends Platform
|
||||
$this->addModule(new Sites\Module());
|
||||
$this->addModule(new Console\Module());
|
||||
$this->addModule(new Proxy\Module());
|
||||
$this->addModule(new Teams\Module());
|
||||
$this->addModule(new Tokens\Module());
|
||||
$this->addModule(new Storage\Module());
|
||||
$this->addModule(new VCS\Module());
|
||||
|
||||
+71
-4
@@ -24,6 +24,7 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Range;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class XList extends Action
|
||||
@@ -70,15 +71,17 @@ class XList extends Action
|
||||
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
|
||||
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('user')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('transactionState')
|
||||
->inject('authorization')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
|
||||
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, StatsUsage $queueForStatsUsage, TransactionState $transactionState, Authorization $authorization): void
|
||||
{
|
||||
$isAPIKey = User::isApp($authorization->getRoles());
|
||||
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
|
||||
@@ -129,9 +132,73 @@ class XList extends Action
|
||||
$documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries);
|
||||
$total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0;
|
||||
} elseif (! empty($selectQueries)) {
|
||||
// has selects, allow relationship on documents
|
||||
$documents = $dbForProject->find($collectionTableId, $queries);
|
||||
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
|
||||
|
||||
if ((int)$ttl > 0) {
|
||||
$serializedQueries = [];
|
||||
foreach ($queries as $query) {
|
||||
$serializedQueries[] = $query instanceof Query ? $query->toArray() : $query;
|
||||
}
|
||||
|
||||
$hostname = $dbForProject->getAdapter()->getHostname();
|
||||
$roles = $dbForProject->getAuthorization()->getRoles();
|
||||
$schemaHash = \md5(\json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', [])));
|
||||
$cacheKeyBase = \sprintf(
|
||||
'%s-cache-%s:%s:%s:collection:%s:%s:user:%s:%s',
|
||||
$dbForProject->getCacheName(),
|
||||
$hostname ?? '',
|
||||
$dbForProject->getNamespace(),
|
||||
$dbForProject->getTenant(),
|
||||
$collectionId,
|
||||
$schemaHash,
|
||||
\md5(\json_encode($roles)),
|
||||
\md5(\json_encode($serializedQueries))
|
||||
);
|
||||
|
||||
$documentsCacheKey = $cacheKeyBase . ':documents';
|
||||
$totalCacheKey = $cacheKeyBase . ':total';
|
||||
|
||||
$documentsCacheHit = $totalDocumentsCacheHit = false;
|
||||
|
||||
$cachedDocuments = $dbForProject->getCache()->load($documentsCacheKey, $ttl);
|
||||
|
||||
if ($cachedDocuments !== null &&
|
||||
$cachedDocuments !== false &&
|
||||
\is_array($cachedDocuments)) {
|
||||
$documents = \array_map(function ($doc) {
|
||||
return new Document($doc);
|
||||
}, $cachedDocuments);
|
||||
$documentsCacheHit = true;
|
||||
} else {
|
||||
$documents = $dbForProject->find($collectionTableId, $queries);
|
||||
|
||||
// Convert Document objects to arrays for caching
|
||||
$documentsArray = \array_map(function ($doc) {
|
||||
return $doc->getArrayCopy();
|
||||
}, $documents);
|
||||
$dbForProject->getCache()->save($documentsCacheKey, $documentsArray);
|
||||
}
|
||||
|
||||
if ($includeTotal) {
|
||||
$cachedTotal = $dbForProject->getCache()->load($totalCacheKey, $ttl);
|
||||
if ($cachedTotal !== null && $cachedTotal !== false) {
|
||||
$total = $cachedTotal;
|
||||
$totalDocumentsCacheHit = true;
|
||||
} else {
|
||||
$total = $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT);
|
||||
$dbForProject->getCache()->save($totalCacheKey, $total);
|
||||
}
|
||||
} else {
|
||||
$total = 0;
|
||||
}
|
||||
|
||||
$response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss');
|
||||
|
||||
} else {
|
||||
// has selects, allow relationship on documents
|
||||
$documents = $dbForProject->find($collectionTableId, $queries);
|
||||
$total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0;
|
||||
}
|
||||
|
||||
} else {
|
||||
// has no selects, disable relationship loading on documents
|
||||
/* @type Document[] $documents */
|
||||
|
||||
@@ -14,6 +14,7 @@ use Utopia\Http\Adapter\Swoole\Response as SwooleResponse;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Range;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class XList extends DocumentXList
|
||||
@@ -56,8 +57,10 @@ class XList extends DocumentXList
|
||||
->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
|
||||
->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject'])
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true)
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('user')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('transactionState')
|
||||
->inject('authorization')
|
||||
|
||||
@@ -90,6 +90,15 @@ class Delete extends Base
|
||||
}
|
||||
$status = $execution->getAttribute('status');
|
||||
|
||||
// Treat timed-out executions as failed so they can be deleted.
|
||||
if ($status === 'waiting' || $status === 'processing') {
|
||||
$timeout = $function->getAttribute('timeout', 900);
|
||||
$elapsed = \time() - \strtotime($execution->getCreatedAt());
|
||||
if ($elapsed >= $timeout) {
|
||||
$status = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
if (!in_array($status, ['completed', 'failed', 'scheduled'])) {
|
||||
throw new Exception(Exception::EXECUTION_IN_PROGRESS);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,16 @@ class Get extends Base
|
||||
throw new Exception(Exception::EXECUTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Override status in response if the execution is stuck in waiting/processing beyond the function timeout.
|
||||
$status = $execution->getAttribute('status', '');
|
||||
if ($status === 'waiting' || $status === 'processing') {
|
||||
$timeout = $function->getAttribute('timeout', 900);
|
||||
$elapsed = \time() - \strtotime($execution->getCreatedAt());
|
||||
if ($elapsed >= $timeout) {
|
||||
$execution->setAttribute('status', 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
$response->dynamic($execution, Response::MODEL_EXECUTION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Database\Validator\Queries\Executions;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Order as OrderException;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
@@ -110,6 +111,35 @@ class XList extends Base
|
||||
$cursor->setValue($cursorDocument);
|
||||
}
|
||||
|
||||
// Calculate the cutoff datetime before which a waiting/processing execution is considered timed out.
|
||||
$timeout = $function->getAttribute('timeout', 900);
|
||||
$thresholdDate = new \DateTime("-{$timeout} seconds");
|
||||
$threshold = DateTime::format($thresholdDate);
|
||||
|
||||
// Capture what statuses the caller explicitly requested, before we mutate the query.
|
||||
$requestedStatuses = [];
|
||||
foreach ($queries as $query) {
|
||||
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
|
||||
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
|
||||
}
|
||||
}
|
||||
|
||||
// If the caller is filtering by 'failed', expand the DB query to also return
|
||||
// waiting/processing executions created before the timeout threshold, so timed-out
|
||||
// executions that were never marked failed in the DB are included in the results.
|
||||
foreach ($queries as $index => $query) {
|
||||
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
|
||||
$queries[$index] = Query::or([
|
||||
$query,
|
||||
Query::and([
|
||||
Query::equal('status', ['waiting', 'processing']),
|
||||
Query::createdBefore($threshold),
|
||||
]),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$filterQueries = Query::groupByType($queries)['filters'];
|
||||
|
||||
try {
|
||||
@@ -119,6 +149,20 @@ class XList extends Base
|
||||
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
|
||||
}
|
||||
|
||||
// Override status in response for timed-out executions, but only when the caller
|
||||
// did not explicitly request a non-failed status (e.g. waiting/processing).
|
||||
if (empty(\array_diff($requestedStatuses, ['failed']))) {
|
||||
foreach ($results as $execution) {
|
||||
$status = $execution->getAttribute('status', '');
|
||||
if ($status === 'waiting' || $status === 'processing') {
|
||||
$elapsed = \time() - \strtotime($execution->getCreatedAt());
|
||||
if ($elapsed >= $timeout) {
|
||||
$execution->setAttribute('status', 'failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'executions' => $results,
|
||||
'total' => $total,
|
||||
|
||||
@@ -71,6 +71,16 @@ class Get extends Base
|
||||
throw new Exception(Exception::LOG_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Override status in response if the log is stuck in waiting/processing beyond the site timeout.
|
||||
$status = $log->getAttribute('status', '');
|
||||
if ($status === 'waiting' || $status === 'processing') {
|
||||
$timeout = $site->getAttribute('timeout', 30);
|
||||
$elapsed = \time() - \strtotime($log->getCreatedAt());
|
||||
if ($elapsed >= $timeout) {
|
||||
$log->setAttribute('status', 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
$response->dynamic($log, Response::MODEL_EXECUTION); //TODO: Change to model log, but model log already exists - decide what to do
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Appwrite\Utopia\Database\Validator\Queries\Executions;
|
||||
use Appwrite\Utopia\Database\Validator\Queries\Logs;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Order as OrderException;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
@@ -99,6 +100,35 @@ class XList extends Base
|
||||
$cursor->setValue($cursorDocument);
|
||||
}
|
||||
|
||||
// Calculate the cutoff datetime before which a waiting/processing log is considered timed out.
|
||||
$timeout = $site->getAttribute('timeout', 30);
|
||||
$thresholdDate = new \DateTime("-{$timeout} seconds");
|
||||
$threshold = DateTime::format($thresholdDate);
|
||||
|
||||
// Capture what statuses the caller explicitly requested, before we mutate the query.
|
||||
$requestedStatuses = [];
|
||||
foreach ($queries as $query) {
|
||||
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status') {
|
||||
$requestedStatuses = [...$requestedStatuses, ...$query->getValues()];
|
||||
}
|
||||
}
|
||||
|
||||
// If the caller is filtering by 'failed', expand the DB query to also return
|
||||
// waiting/processing logs created before the timeout threshold, so timed-out
|
||||
// logs that were never marked failed in the DB are included in the results.
|
||||
foreach ($queries as $index => $query) {
|
||||
if ($query->getMethod() === Query::TYPE_EQUAL && $query->getAttribute() === 'status' && \in_array('failed', $query->getValues())) {
|
||||
$queries[$index] = Query::or([
|
||||
$query,
|
||||
Query::and([
|
||||
Query::equal('status', ['waiting', 'processing']),
|
||||
Query::createdBefore($threshold),
|
||||
]),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$filterQueries = Query::groupByType($queries)['filters'];
|
||||
|
||||
try {
|
||||
@@ -108,6 +138,20 @@ class XList extends Base
|
||||
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
|
||||
}
|
||||
|
||||
// Override status in response for timed-out logs, but only when the caller
|
||||
// did not explicitly request a non-failed status (e.g. waiting/processing).
|
||||
if (empty(\array_diff($requestedStatuses, ['failed']))) {
|
||||
foreach ($results as $log) {
|
||||
$status = $log->getAttribute('status', '');
|
||||
if ($status === 'waiting' || $status === 'processing') {
|
||||
$elapsed = \time() - \strtotime($log->getCreatedAt());
|
||||
if ($elapsed >= $timeout) {
|
||||
$log->setAttribute('status', 'failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'executions' => $results,
|
||||
'total' => $total,
|
||||
|
||||
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Response;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Duplicate as DuplicateException;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
@@ -136,7 +137,7 @@ class Create extends Base
|
||||
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".');
|
||||
}
|
||||
|
||||
$site = $dbForProject->createDocument('sites', new Document([
|
||||
$site = new Document([
|
||||
'$id' => $siteId,
|
||||
'enabled' => $enabled,
|
||||
'live' => true,
|
||||
@@ -166,13 +167,17 @@ class Create extends Base
|
||||
'runtimeSpecification' => $specification,
|
||||
'buildRuntime' => $buildRuntime,
|
||||
'adapter' => $adapter,
|
||||
]));
|
||||
]);
|
||||
|
||||
try {
|
||||
$site = $dbForProject->createDocument('sites', $site);
|
||||
} catch (DuplicateException) {
|
||||
throw new Exception(Exception::SITE_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
// Git connect logic
|
||||
if (!empty($providerRepositoryId)) {
|
||||
$teamId = $project->getAttribute('teamId', '');
|
||||
|
||||
$repository = $dbForPlatform->createDocument('repositories', new Document([
|
||||
$repository = new Document([
|
||||
'$id' => ID::unique(),
|
||||
'$permissions' => $this->getPermissions($teamId, $project->getId()),
|
||||
'installationId' => $installation->getId(),
|
||||
@@ -184,8 +189,8 @@ class Create extends Base
|
||||
'resourceInternalId' => $site->getSequence(),
|
||||
'resourceType' => 'site',
|
||||
'providerPullRequestIds' => []
|
||||
]));
|
||||
|
||||
]);
|
||||
$repository = $dbForPlatform->createDocument('repositories', $repository);
|
||||
$site->setAttribute('repositoryId', $repository->getId());
|
||||
$site->setAttribute('repositoryInternalId', $repository->getSequence());
|
||||
}
|
||||
|
||||
@@ -190,11 +190,9 @@ class Update extends Base
|
||||
$repositoryInternalId = '';
|
||||
}
|
||||
|
||||
// Git connect logic
|
||||
if (!$isConnected && !empty($providerRepositoryId)) {
|
||||
$teamId = $project->getAttribute('teamId', '');
|
||||
|
||||
$repository = $dbForPlatform->createDocument('repositories', new Document([
|
||||
$repository = new Document([
|
||||
'$id' => ID::unique(),
|
||||
'$permissions' => $this->getPermissions($teamId, $project->getId()),
|
||||
'installationId' => $installation->getId(),
|
||||
@@ -206,8 +204,8 @@ class Update extends Base
|
||||
'resourceInternalId' => $site->getSequence(),
|
||||
'resourceType' => 'site',
|
||||
'providerPullRequestIds' => []
|
||||
]));
|
||||
|
||||
]);
|
||||
$repository = $dbForPlatform->createDocument('repositories', $repository);
|
||||
$repositoryId = $repository->getId();
|
||||
$repositoryInternalId = $repository->getSequence();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Logs;
|
||||
|
||||
use Appwrite\Detector\Detector;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use MaxMind\Db\Reader;
|
||||
use Utopia\Audit\Audit;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Queries;
|
||||
use Utopia\Database\Validator\Query\Limit;
|
||||
use Utopia\Database\Validator\Query\Offset;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Locale\Locale;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
|
||||
class XList extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'listTeamLogs';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/teams/:teamId/logs')
|
||||
->desc('List team logs')
|
||||
->groups(['api', 'teams'])
|
||||
->label('scope', 'teams.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'logs',
|
||||
name: 'listLogs',
|
||||
description: '/docs/references/teams/get-team-logs.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_LOG_LIST,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('locale')
|
||||
->inject('geodb')
|
||||
->inject('audit')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Audit $audit)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$queries = Query::parseQueries($queries);
|
||||
} catch (QueryException $e) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
|
||||
}
|
||||
|
||||
$grouped = Query::groupByType($queries);
|
||||
$limit = $grouped['limit'] ?? 25;
|
||||
$offset = $grouped['offset'] ?? 0;
|
||||
|
||||
$resource = 'team/' . $team->getId();
|
||||
$logs = $audit->getLogsByResource($resource, offset: $offset, limit: $limit);
|
||||
|
||||
$output = [];
|
||||
|
||||
foreach ($logs as $i => &$log) {
|
||||
$log['userAgent'] = (!empty($log['userAgent'])) ? $log['userAgent'] : 'UNKNOWN';
|
||||
|
||||
$detector = new Detector($log['userAgent']);
|
||||
$detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then)
|
||||
|
||||
$os = $detector->getOS();
|
||||
$client = $detector->getClient();
|
||||
$device = $detector->getDevice();
|
||||
|
||||
$output[$i] = new Document([
|
||||
'event' => $log['event'],
|
||||
'userId' => $log['data']['userId'],
|
||||
'userEmail' => $log['data']['userEmail'] ?? null,
|
||||
'userName' => $log['data']['userName'] ?? null,
|
||||
'mode' => $log['data']['mode'] ?? null,
|
||||
'ip' => $log['ip'],
|
||||
'time' => $log['time'],
|
||||
'osCode' => $os['osCode'],
|
||||
'osName' => $os['osName'],
|
||||
'osVersion' => $os['osVersion'],
|
||||
'clientType' => $client['clientType'],
|
||||
'clientCode' => $client['clientCode'],
|
||||
'clientName' => $client['clientName'],
|
||||
'clientVersion' => $client['clientVersion'],
|
||||
'clientEngine' => $client['clientEngine'],
|
||||
'clientEngineVersion' => $client['clientEngineVersion'],
|
||||
'deviceName' => $device['deviceName'],
|
||||
'deviceBrand' => $device['deviceBrand'],
|
||||
'deviceModel' => $device['deviceModel']
|
||||
]);
|
||||
|
||||
$record = $geodb->get($log['ip']);
|
||||
|
||||
if ($record) {
|
||||
$output[$i]['countryCode'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), false) ? \strtolower($record['country']['iso_code']) : '--';
|
||||
$output[$i]['countryName'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), $locale->getText('locale.country.unknown'));
|
||||
} else {
|
||||
$output[$i]['countryCode'] = '--';
|
||||
$output[$i]['countryName'] = $locale->getText('locale.country.unknown');
|
||||
}
|
||||
}
|
||||
$response->dynamic(new Document([
|
||||
'total' => $includeTotal ? $audit->countLogsByResource($resource) : 0,
|
||||
'logs' => $output,
|
||||
]), Response::MODEL_LOG_LIST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
|
||||
|
||||
use Appwrite\Auth\Validator\Phone;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Event\Mail;
|
||||
use Appwrite\Event\Messaging;
|
||||
use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Network\Validator\Email as EmailValidator;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Template\Template;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Response;
|
||||
use libphonenumber\NumberParseException;
|
||||
use libphonenumber\PhoneNumberUtil;
|
||||
use Utopia\Auth\Proofs\Password;
|
||||
use Utopia\Auth\Proofs\Token;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Duplicate;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Key;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Emails\Email;
|
||||
use Utopia\Locale\Locale;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\System\System;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Create extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'createTeamMembership';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
|
||||
->setHttpPath('/v1/teams/:teamId/memberships')
|
||||
->desc('Create team membership')
|
||||
->groups(['api', 'teams', 'auth'])
|
||||
->label('event', 'teams.[teamId].memberships.[membershipId].create')
|
||||
->label('scope', 'teams.write')
|
||||
->label('auth.type', 'invites')
|
||||
->label('audits.event', 'membership.create')
|
||||
->label('audits.resource', 'team/{request.teamId}')
|
||||
->label('audits.userId', '{request.userId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'memberships',
|
||||
name: 'createMembership',
|
||||
description: '/docs/references/teams/create-team-membership.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_CREATED,
|
||||
model: Response::MODEL_MEMBERSHIP,
|
||||
)
|
||||
]
|
||||
))
|
||||
->label('abuse-limit', 10)
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('email', '', new EmailValidator(), 'Email of the new team member.', true)
|
||||
->param('userId', '', new UID(), 'ID of the user to be added to a team.', true)
|
||||
->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
|
||||
->param('roles', [], new ArrayList(new Key(maxLength: 81), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 81 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
|
||||
->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // TODO add our own built-in confirm page
|
||||
->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true)
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('authorization')
|
||||
->inject('locale')
|
||||
->inject('queueForMails')
|
||||
->inject('queueForMessaging')
|
||||
->inject('queueForEvents')
|
||||
->inject('timelimit')
|
||||
->inject('queueForStatsUsage')
|
||||
->inject('plan')
|
||||
->inject('proofForPassword')
|
||||
->inject('proofForToken')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Password $proofForPassword, Token $proofForToken)
|
||||
{
|
||||
$isAppUser = User::isApp($authorization->getRoles());
|
||||
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
|
||||
|
||||
if (empty($url)) {
|
||||
if (!$isAppUser && !$isPrivilegedUser) {
|
||||
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required');
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($userId) && empty($email) && empty($phone)) {
|
||||
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'At least one of userId, email, or phone is required');
|
||||
}
|
||||
|
||||
if (!$isPrivilegedUser && !$isAppUser && empty(System::getEnv('_APP_SMTP_HOST'))) {
|
||||
throw new Exception(Exception::GENERAL_SMTP_DISABLED);
|
||||
}
|
||||
|
||||
$email = \strtolower($email);
|
||||
$name = empty($name) ? $email : $name;
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
if (!empty($userId)) {
|
||||
$invitee = $dbForProject->getDocument('users', $userId);
|
||||
if ($invitee->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND, 'User with given userId doesn\'t exist.', 404);
|
||||
}
|
||||
if (!empty($email) && $invitee->getAttribute('email', '') !== $email) {
|
||||
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and email doesn\'t match', 409);
|
||||
}
|
||||
if (!empty($phone) && $invitee->getAttribute('phone', '') !== $phone) {
|
||||
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given userId and phone doesn\'t match', 409);
|
||||
}
|
||||
$email = $invitee->getAttribute('email', '');
|
||||
$phone = $invitee->getAttribute('phone', '');
|
||||
$name = $invitee->getAttribute('name', '') ?: $name;
|
||||
} elseif (!empty($email)) {
|
||||
$invitee = $dbForProject->findOne('users', [Query::equal('email', [$email])]); // Get user by email address
|
||||
if (!$invitee->isEmpty() && !empty($phone) && $invitee->getAttribute('phone', '') !== $phone) {
|
||||
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given email and phone doesn\'t match', 409);
|
||||
}
|
||||
} elseif (!empty($phone)) {
|
||||
$invitee = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]);
|
||||
if (!$invitee->isEmpty() && !empty($email) && $invitee->getAttribute('email', '') !== $email) {
|
||||
throw new Exception(Exception::USER_ALREADY_EXISTS, 'Given phone and email doesn\'t match', 409);
|
||||
}
|
||||
}
|
||||
|
||||
if ($invitee->isEmpty()) { // Create new user if no user with same email found
|
||||
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
|
||||
|
||||
if (!$isPrivilegedUser && !$isAppUser && $limit !== 0 && $project->getId() !== 'console') { // check users limit, console invites are allways allowed.
|
||||
$total = $dbForProject->count('users', [], APP_LIMIT_USERS);
|
||||
|
||||
if ($total >= $limit) {
|
||||
throw new Exception(Exception::USER_COUNT_EXCEEDED, 'Project registration is restricted. Contact your administrator for more information.');
|
||||
}
|
||||
}
|
||||
|
||||
// Makes sure this email is not already used in another identity
|
||||
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
|
||||
Query::equal('providerEmail', [$email]),
|
||||
]);
|
||||
if (!$identityWithMatchingEmail->isEmpty()) {
|
||||
throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
try {
|
||||
$userId = ID::unique();
|
||||
$hash = $proofForPassword->hash($proofForPassword->generate());
|
||||
$emailCanonical = new Email($email);
|
||||
} catch (Throwable) {
|
||||
$emailCanonical = null;
|
||||
}
|
||||
|
||||
$userId = ID::unique();
|
||||
|
||||
$userDocument = new Document([
|
||||
'$id' => $userId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::read(Role::user($userId)),
|
||||
Permission::update(Role::user($userId)),
|
||||
Permission::delete(Role::user($userId)),
|
||||
],
|
||||
'email' => empty($email) ? null : $email,
|
||||
'phone' => empty($phone) ? null : $phone,
|
||||
'emailVerification' => false,
|
||||
'status' => true,
|
||||
// TODO: Set password empty?
|
||||
'password' => $hash,
|
||||
'hash' => $proofForPassword->getHash()->getName(),
|
||||
'hashOptions' => $proofForPassword->getHash()->getOptions(),
|
||||
/**
|
||||
* Set the password update time to 0 for users created using
|
||||
* team invite and OAuth to allow password updates without an
|
||||
* old password
|
||||
*/
|
||||
'passwordUpdate' => null,
|
||||
'registration' => DateTime::now(),
|
||||
'reset' => false,
|
||||
'name' => $name,
|
||||
'prefs' => new \stdClass(),
|
||||
'sessions' => null,
|
||||
'tokens' => null,
|
||||
'memberships' => null,
|
||||
'search' => implode(' ', [$userId, $email, $name]),
|
||||
'emailCanonical' => $emailCanonical?->getCanonical(),
|
||||
'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(),
|
||||
'emailIsCorporate' => $emailCanonical?->isCorporate(),
|
||||
'emailIsDisposable' => $emailCanonical?->isDisposable(),
|
||||
'emailIsFree' => $emailCanonical?->isFree(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$invitee = $authorization->skip(fn () => $dbForProject->createDocument('users', $userDocument));
|
||||
} catch (Duplicate $th) {
|
||||
throw new Exception(Exception::USER_ALREADY_EXISTS);
|
||||
}
|
||||
}
|
||||
|
||||
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
|
||||
|
||||
if (!$isOwner && !$isPrivilegedUser && !$isAppUser) { // Not owner, not admin, not app (server)
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not allowed to send invitations for this team');
|
||||
}
|
||||
|
||||
$membership = $dbForProject->findOne('memberships', [
|
||||
Query::equal('userInternalId', [$invitee->getSequence()]),
|
||||
Query::equal('teamInternalId', [$team->getSequence()]),
|
||||
]);
|
||||
|
||||
$secret = $proofForToken->generate();
|
||||
if ($membership->isEmpty()) {
|
||||
$membershipId = ID::unique();
|
||||
$membership = new Document([
|
||||
'$id' => $membershipId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::user($invitee->getId())),
|
||||
Permission::update(Role::team($team->getId(), 'owner')),
|
||||
Permission::delete(Role::user($invitee->getId())),
|
||||
Permission::delete(Role::team($team->getId(), 'owner')),
|
||||
],
|
||||
'userId' => $invitee->getId(),
|
||||
'userInternalId' => $invitee->getSequence(),
|
||||
'teamId' => $team->getId(),
|
||||
'teamInternalId' => $team->getSequence(),
|
||||
'roles' => $roles,
|
||||
'invited' => DateTime::now(),
|
||||
'joined' => ($isPrivilegedUser || $isAppUser) ? DateTime::now() : null,
|
||||
'confirm' => ($isPrivilegedUser || $isAppUser),
|
||||
'secret' => $proofForToken->hash($secret),
|
||||
'search' => implode(' ', [$membershipId, $invitee->getId()])
|
||||
]);
|
||||
|
||||
$membership = ($isPrivilegedUser || $isAppUser) ?
|
||||
$authorization->skip(fn () => $dbForProject->createDocument('memberships', $membership)) :
|
||||
$dbForProject->createDocument('memberships', $membership);
|
||||
|
||||
if ($isPrivilegedUser || $isAppUser) {
|
||||
$authorization->skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1));
|
||||
}
|
||||
} elseif ($membership->getAttribute('confirm') === false) {
|
||||
$membership->setAttribute('secret', $proofForToken->hash($secret));
|
||||
$membership->setAttribute('invited', DateTime::now());
|
||||
|
||||
if ($isPrivilegedUser || $isAppUser) {
|
||||
$membership->setAttribute('joined', DateTime::now());
|
||||
$membership->setAttribute('confirm', true);
|
||||
}
|
||||
|
||||
$membership = ($isPrivilegedUser || $isAppUser) ?
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('memberships', $membership->getId(), $membership)) :
|
||||
$dbForProject->updateDocument('memberships', $membership->getId(), $membership);
|
||||
} else {
|
||||
throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED);
|
||||
}
|
||||
|
||||
if ($isPrivilegedUser || $isAppUser) {
|
||||
$dbForProject->purgeCachedDocument('users', $invitee->getId());
|
||||
} else {
|
||||
$url = Template::parseURL($url);
|
||||
$url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['membershipId' => $membership->getId(), 'userId' => $invitee->getId(), 'secret' => $secret, 'teamId' => $teamId, 'teamName' => $team->getAttribute('name')]);
|
||||
$url = Template::unParseURL($url);
|
||||
if (!empty($email)) {
|
||||
$projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]');
|
||||
|
||||
$body = $locale->getText("emails.invitation.body");
|
||||
$preview = $locale->getText("emails.invitation.preview");
|
||||
$subject = $locale->getText("emails.invitation.subject");
|
||||
$customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? [];
|
||||
|
||||
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl');
|
||||
$message
|
||||
->setParam('{{body}}', $body, escapeHtml: false)
|
||||
->setParam('{{hello}}', $locale->getText("emails.invitation.hello"))
|
||||
->setParam('{{footer}}', $locale->getText("emails.invitation.footer"))
|
||||
->setParam('{{thanks}}', $locale->getText("emails.invitation.thanks"))
|
||||
->setParam('{{buttonText}}', $locale->getText("emails.invitation.buttonText"))
|
||||
->setParam('{{signature}}', $locale->getText("emails.invitation.signature"));
|
||||
$body = $message->render();
|
||||
|
||||
$smtp = $project->getAttribute('smtp', []);
|
||||
$smtpEnabled = $smtp['enabled'] ?? false;
|
||||
|
||||
$senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
|
||||
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
|
||||
$replyTo = "";
|
||||
|
||||
if ($smtpEnabled) {
|
||||
if (!empty($smtp['senderEmail'])) {
|
||||
$senderEmail = $smtp['senderEmail'];
|
||||
}
|
||||
if (!empty($smtp['senderName'])) {
|
||||
$senderName = $smtp['senderName'];
|
||||
}
|
||||
if (!empty($smtp['replyTo'])) {
|
||||
$replyTo = $smtp['replyTo'];
|
||||
}
|
||||
|
||||
$queueForMails
|
||||
->setSmtpHost($smtp['host'] ?? '')
|
||||
->setSmtpPort($smtp['port'] ?? '')
|
||||
->setSmtpUsername($smtp['username'] ?? '')
|
||||
->setSmtpPassword($smtp['password'] ?? '')
|
||||
->setSmtpSecure($smtp['secure'] ?? '');
|
||||
|
||||
if (!empty($customTemplate)) {
|
||||
if (!empty($customTemplate['senderEmail'])) {
|
||||
$senderEmail = $customTemplate['senderEmail'];
|
||||
}
|
||||
if (!empty($customTemplate['senderName'])) {
|
||||
$senderName = $customTemplate['senderName'];
|
||||
}
|
||||
if (!empty($customTemplate['replyTo'])) {
|
||||
$replyTo = $customTemplate['replyTo'];
|
||||
}
|
||||
|
||||
$body = $customTemplate['message'] ?? '';
|
||||
$subject = $customTemplate['subject'] ?? $subject;
|
||||
}
|
||||
|
||||
$queueForMails
|
||||
->setSmtpReplyTo($replyTo)
|
||||
->setSmtpSenderEmail($senderEmail)
|
||||
->setSmtpSenderName($senderName);
|
||||
}
|
||||
|
||||
$emailVariables = [
|
||||
'owner' => $user->getAttribute('name'),
|
||||
'direction' => $locale->getText('settings.direction'),
|
||||
/* {{user}}, {{team}}, {{redirect}} and {{project}} are required in default and custom templates */
|
||||
'user' => $name,
|
||||
'team' => $team->getAttribute('name'),
|
||||
'redirect' => $url,
|
||||
'project' => $projectName
|
||||
];
|
||||
|
||||
$queueForMails
|
||||
->setSubject($subject)
|
||||
->setBody($body)
|
||||
->setPreview($preview)
|
||||
->setRecipient($invitee->getAttribute('email'))
|
||||
->setName($invitee->getAttribute('name', ''))
|
||||
->appendVariables($emailVariables)
|
||||
->trigger();
|
||||
} elseif (!empty($phone)) {
|
||||
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
|
||||
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
|
||||
}
|
||||
|
||||
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl');
|
||||
|
||||
$customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? [];
|
||||
if (!empty($customTemplate)) {
|
||||
$message = $customTemplate['message'];
|
||||
}
|
||||
|
||||
$message = $message->setParam('{{token}}', $url);
|
||||
$message = $message->render();
|
||||
|
||||
$messageDoc = new Document([
|
||||
'$id' => ID::unique(),
|
||||
'data' => [
|
||||
'content' => $message,
|
||||
],
|
||||
]);
|
||||
|
||||
$queueForMessaging
|
||||
->setType(MESSAGE_SEND_TYPE_INTERNAL)
|
||||
->setMessage($messageDoc)
|
||||
->setRecipients([$phone])
|
||||
->setProviderType('SMS');
|
||||
|
||||
$helper = PhoneNumberUtil::getInstance();
|
||||
try {
|
||||
$countryCode = $helper->parse($phone)->getCountryCode();
|
||||
|
||||
if (!empty($countryCode)) {
|
||||
$queueForStatsUsage
|
||||
->addMetric(str_replace('{countryCode}', $countryCode, METRIC_AUTH_METHOD_PHONE_COUNTRY_CODE), 1);
|
||||
}
|
||||
} catch (NumberParseException $e) {
|
||||
// Ignore invalid phone number for country code stats
|
||||
}
|
||||
$queueForStatsUsage
|
||||
->addMetric(METRIC_AUTH_METHOD_PHONE, 1)
|
||||
->setProject($project)
|
||||
->trigger();
|
||||
}
|
||||
}
|
||||
|
||||
$queueForEvents
|
||||
->setParam('userId', $invitee->getId())
|
||||
->setParam('teamId', $team->getId())
|
||||
->setParam('membershipId', $membership->getId())
|
||||
;
|
||||
|
||||
$response
|
||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||
->dynamic(
|
||||
$membership
|
||||
->setAttribute('teamName', $team->getAttribute('name'))
|
||||
->setAttribute('userName', $invitee->getAttribute('name'))
|
||||
->setAttribute('userEmail', $invitee->getAttribute('email')),
|
||||
Response::MODEL_MEMBERSHIP
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Authorization as AuthorizationException;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
||||
class Delete extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'deleteTeamMembership';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
|
||||
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
|
||||
->desc('Delete team membership')
|
||||
->groups(['api', 'teams'])
|
||||
->label('event', 'teams.[teamId].memberships.[membershipId].delete')
|
||||
->label('scope', 'teams.write')
|
||||
->label('audits.event', 'membership.delete')
|
||||
->label('audits.resource', 'team/{request.teamId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'memberships',
|
||||
name: 'deleteMembership',
|
||||
description: '/docs/references/teams/delete-team-membership.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_NOCONTENT,
|
||||
model: Response::MODEL_NONE,
|
||||
)
|
||||
],
|
||||
contentType: ContentType::NONE
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('membershipId', '', new UID(), 'Membership ID.')
|
||||
->inject('user')
|
||||
->inject('project')
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('authorization')
|
||||
->inject('queueForEvents')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, string $membershipId, Document $user, Document $project, Response $response, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
|
||||
{
|
||||
$membership = $dbForProject->getDocument('memberships', $membershipId);
|
||||
if ($membership->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_INVITE_NOT_FOUND);
|
||||
}
|
||||
|
||||
$profile = $dbForProject->getDocument('users', $membership->getAttribute('userId'));
|
||||
if ($profile->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($membership->getAttribute('teamInternalId') !== $team->getSequence()) {
|
||||
throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH);
|
||||
}
|
||||
|
||||
$this->validate($profile, $team, $dbForProject);
|
||||
|
||||
if ($project->getId() === 'console') {
|
||||
// Quick check:
|
||||
// fetch up to 2 owners to determine if only one exists
|
||||
$ownersCount = $dbForProject->count(
|
||||
collection: 'memberships',
|
||||
queries: [
|
||||
Query::contains('roles', ['owner']),
|
||||
Query::equal('teamInternalId', [$team->getSequence()])
|
||||
],
|
||||
max: 2
|
||||
);
|
||||
|
||||
// Is the deletion being requested by the user on their own membership and they are also the owner?
|
||||
$isSelfOwner =
|
||||
in_array('owner', $membership->getAttribute('roles')) &&
|
||||
$membership->getAttribute('userInternalId') === $user->getSequence();
|
||||
|
||||
if ($ownersCount === 1 && $isSelfOwner) {
|
||||
/**
|
||||
* Prevent removal if the user is the only owner, this is because -
|
||||
*
|
||||
* 1. Other roles [if exists] can neither add a new owner nor delete the organization.
|
||||
* 2. If the only owner is removed, while there were no other members, the organization isn't marked for deletion and stays in a limbo.
|
||||
*/
|
||||
throw new Exception(Exception::MEMBERSHIP_DELETION_PROHIBITED, 'There must be at least one owner in the organization.');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$dbForProject->deleteDocument('memberships', $membership->getId());
|
||||
} catch (AuthorizationException $exception) {
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$dbForProject->purgeCachedDocument('users', $profile->getId());
|
||||
|
||||
// This membership is primary for the team, update the primary to next member.
|
||||
if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) {
|
||||
$membership = $dbForProject->findOne('memberships', [
|
||||
Query::equal('teamInternalId', [$team->getSequence()]),
|
||||
]);
|
||||
|
||||
if (!$membership->isEmpty()) {
|
||||
$team->setAttribute('userId', $membership->getAttribute('userId'));
|
||||
$team->setAttribute('userInternalId', $membership->getAttribute('userInternalId'));
|
||||
$dbForProject->updateDocument('teams', $team->getId(), $team);
|
||||
}
|
||||
}
|
||||
|
||||
if ($membership->getAttribute('confirm')) { // Count only confirmed members
|
||||
$authorization->skip(fn () => $dbForProject->decreaseDocumentAttribute('teams', $team->getId(), 'total', 1, 0));
|
||||
}
|
||||
|
||||
$queueForEvents
|
||||
->setParam('teamId', $team->getId())
|
||||
->setParam('userId', $profile->getId())
|
||||
->setParam('membershipId', $membership->getId())
|
||||
->setPayload($response->output($membership, Response::MODEL_MEMBERSHIP));
|
||||
|
||||
$response->noContent();
|
||||
}
|
||||
|
||||
protected function validate(Document $profile, Document $team, Database $dbForProject): void
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
|
||||
|
||||
use Appwrite\Auth\MFA\Type\TOTP;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
||||
class Get extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'getTeamMembership';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
|
||||
->desc('Get team membership')
|
||||
->groups(['api', 'teams'])
|
||||
->label('scope', 'teams.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'memberships',
|
||||
name: 'getMembership',
|
||||
description: '/docs/references/teams/get-team-member.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_MEMBERSHIP,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('membershipId', '', new UID(), 'Membership ID.')
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('authorization')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
$membership = $dbForProject->getDocument('memberships', $membershipId);
|
||||
|
||||
if ($membership->isEmpty() || empty($membership->getAttribute('userId'))) {
|
||||
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$membershipsPrivacy = [
|
||||
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
|
||||
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
|
||||
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
|
||||
];
|
||||
|
||||
$roles = $authorization->getRoles();
|
||||
$isPrivilegedUser = User::isPrivileged($roles);
|
||||
$isAppUser = User::isApp($roles);
|
||||
|
||||
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
|
||||
return $privacy || $isPrivilegedUser || $isAppUser;
|
||||
}, $membershipsPrivacy);
|
||||
|
||||
$user = !empty(array_filter($membershipsPrivacy))
|
||||
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
|
||||
: new Document();
|
||||
|
||||
if ($membershipsPrivacy['mfa']) {
|
||||
$mfa = $user->getAttribute('mfa', false);
|
||||
|
||||
if ($mfa) {
|
||||
$totp = TOTP::getAuthenticatorFromUser($user);
|
||||
$totpEnabled = $totp && $totp->getAttribute('verified', false);
|
||||
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
|
||||
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
|
||||
|
||||
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
|
||||
$mfa = false;
|
||||
}
|
||||
}
|
||||
|
||||
$membership->setAttribute('mfa', $mfa);
|
||||
}
|
||||
|
||||
if ($membershipsPrivacy['userName']) {
|
||||
$membership->setAttribute('userName', $user->getAttribute('name'));
|
||||
}
|
||||
|
||||
if ($membershipsPrivacy['userEmail']) {
|
||||
$membership->setAttribute('userEmail', $user->getAttribute('email'));
|
||||
}
|
||||
|
||||
$membership->setAttribute('teamName', $team->getAttribute('name'));
|
||||
|
||||
$response->dynamic($membership, Response::MODEL_MEMBERSHIP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Memberships\Status;
|
||||
|
||||
use Appwrite\Detector\Detector;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
use MaxMind\Db\Reader;
|
||||
use Utopia\Auth\Proofs\Token;
|
||||
use Utopia\Auth\Store;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Update extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'updateTeamMembershipStatus';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
|
||||
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId/status')
|
||||
->desc('Update team membership status')
|
||||
->groups(['api', 'teams'])
|
||||
->label('event', 'teams.[teamId].memberships.[membershipId].update.status')
|
||||
->label('scope', 'public')
|
||||
->label('audits.event', 'membership.update')
|
||||
->label('audits.resource', 'team/{request.teamId}')
|
||||
->label('audits.userId', '{request.userId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'memberships',
|
||||
name: 'updateMembershipStatus',
|
||||
description: '/docs/references/teams/update-team-membership-status.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_MEMBERSHIP,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('membershipId', '', new UID(), 'Membership ID.')
|
||||
->param('userId', '', new UID(), 'User ID.')
|
||||
->param('secret', '', new Text(256), 'Secret key.')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('authorization')
|
||||
->inject('project')
|
||||
->inject('geodb')
|
||||
->inject('queueForEvents')
|
||||
->inject('store')
|
||||
->inject('proofForToken')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Authorization $authorization, $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken)
|
||||
{
|
||||
$protocol = $request->getProtocol();
|
||||
|
||||
$membership = $dbForProject->getDocument('memberships', $membershipId);
|
||||
|
||||
if ($membership->isEmpty()) {
|
||||
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$team = $authorization->skip(fn () => $dbForProject->getDocument('teams', $teamId));
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($membership->getAttribute('teamInternalId') !== $team->getSequence()) {
|
||||
throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH);
|
||||
}
|
||||
|
||||
if (!$proofForToken->verify($secret, $membership->getAttribute('secret'))) {
|
||||
throw new Exception(Exception::TEAM_INVALID_SECRET);
|
||||
}
|
||||
|
||||
if ($userId !== $membership->getAttribute('userId')) {
|
||||
throw new Exception(Exception::TEAM_INVITE_MISMATCH, 'Invite does not belong to current user (' . $user->getAttribute('email') . ')');
|
||||
}
|
||||
|
||||
$hasSession = !$user->isEmpty();
|
||||
if (!$hasSession) {
|
||||
$user->setAttributes($dbForProject->getDocument('users', $userId)->getArrayCopy()); // Get user
|
||||
}
|
||||
|
||||
if ($membership->getAttribute('userInternalId') !== $user->getSequence()) {
|
||||
throw new Exception(Exception::TEAM_INVITE_MISMATCH, 'Invite does not belong to current user (' . $user->getAttribute('email') . ')');
|
||||
}
|
||||
|
||||
if ($membership->getAttribute('confirm') === true) {
|
||||
throw new Exception(Exception::MEMBERSHIP_ALREADY_CONFIRMED);
|
||||
}
|
||||
|
||||
$membership // Attach user to team
|
||||
->setAttribute('joined', DateTime::now())
|
||||
->setAttribute('confirm', true)
|
||||
;
|
||||
|
||||
$authorization->skip(fn () => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true)));
|
||||
|
||||
// Create session for the user if not logged in
|
||||
if (!$hasSession) {
|
||||
$authorization->addRole(Role::user($user->getId())->toString());
|
||||
|
||||
$detector = new Detector($request->getUserAgent('UNKNOWN'));
|
||||
$record = $geodb->get($request->getIP());
|
||||
$authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
|
||||
$expire = DateTime::addSeconds(new \DateTime(), $authDuration);
|
||||
$secret = $proofForToken->generate();
|
||||
$session = new Document(array_merge([
|
||||
'$id' => ID::unique(),
|
||||
'$permissions' => [
|
||||
Permission::read(Role::user($user->getId())),
|
||||
Permission::update(Role::user($user->getId())),
|
||||
Permission::delete(Role::user($user->getId())),
|
||||
],
|
||||
'userId' => $user->getId(),
|
||||
'userInternalId' => $user->getSequence(),
|
||||
'provider' => SESSION_PROVIDER_EMAIL,
|
||||
'providerUid' => $user->getAttribute('email'),
|
||||
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
|
||||
'userAgent' => $request->getUserAgent('UNKNOWN'),
|
||||
'ip' => $request->getIP(),
|
||||
'factors' => ['email'],
|
||||
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
|
||||
'expire' => DateTime::addSeconds(new \DateTime(), $authDuration)
|
||||
], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
|
||||
|
||||
$session = $dbForProject->createDocument('sessions', $session);
|
||||
|
||||
$authorization->addRole(Role::user($userId)->toString());
|
||||
|
||||
$encoded = $store
|
||||
->setProperty('id', $user->getId())
|
||||
->setProperty('secret', $secret)
|
||||
->encode();
|
||||
|
||||
if (!Config::getParam('domainVerification')) {
|
||||
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
|
||||
}
|
||||
|
||||
$response
|
||||
->addCookie(
|
||||
name: $store->getKey() . '_legacy',
|
||||
value: $encoded,
|
||||
expire: (new \DateTime($expire))->getTimestamp(),
|
||||
path: '/',
|
||||
domain: Config::getParam('cookieDomain'),
|
||||
secure: ('https' === $protocol),
|
||||
httponly: true
|
||||
)
|
||||
->addCookie(
|
||||
name: $store->getKey(),
|
||||
value: $encoded,
|
||||
expire: (new \DateTime($expire))->getTimestamp(),
|
||||
path: '/',
|
||||
domain: Config::getParam('cookieDomain'),
|
||||
secure: ('https' === $protocol),
|
||||
httponly: true,
|
||||
sameSite: Config::getParam('cookieSamesite')
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
$membership = $dbForProject->updateDocument('memberships', $membership->getId(), $membership);
|
||||
|
||||
$dbForProject->purgeCachedDocument('users', $user->getId());
|
||||
|
||||
$authorization->skip(fn () => $dbForProject->increaseDocumentAttribute('teams', $team->getId(), 'total', 1));
|
||||
|
||||
$queueForEvents
|
||||
->setParam('userId', $user->getId())
|
||||
->setParam('teamId', $team->getId())
|
||||
->setParam('membershipId', $membership->getId())
|
||||
;
|
||||
|
||||
$response->dynamic(
|
||||
$membership
|
||||
->setAttribute('teamName', $team->getAttribute('name'))
|
||||
->setAttribute('userName', $user->getAttribute('name'))
|
||||
->setAttribute('userEmail', $user->getAttribute('email')),
|
||||
Response::MODEL_MEMBERSHIP
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Request;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Key;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\ArrayList;
|
||||
|
||||
class Update extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'updateTeamMembership';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
|
||||
->setHttpPath('/v1/teams/:teamId/memberships/:membershipId')
|
||||
->desc('Update team membership')
|
||||
->groups(['api', 'teams'])
|
||||
->label('event', 'teams.[teamId].memberships.[membershipId].update')
|
||||
->label('scope', 'teams.write')
|
||||
->label('audits.event', 'membership.update')
|
||||
->label('audits.resource', 'team/{request.teamId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'memberships',
|
||||
name: 'updateMembership',
|
||||
description: '/docs/references/teams/update-team-membership.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_MEMBERSHIP,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('membershipId', '', new UID(), 'Membership ID.')
|
||||
->param('roles', [], new ArrayList(new Key(maxLength: 81), APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 81 characters long.', false, ['project']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('authorization')
|
||||
->inject('queueForEvents')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
$membership = $dbForProject->getDocument('memberships', $membershipId);
|
||||
if ($membership->isEmpty()) {
|
||||
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$profile = $dbForProject->getDocument('users', $membership->getAttribute('userId'));
|
||||
if ($profile->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
|
||||
$isAppUser = User::isApp($authorization->getRoles());
|
||||
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
|
||||
|
||||
if ($project->getId() === 'console') {
|
||||
// Quick check: fetch up to 2 owners to determine if only one exists
|
||||
$ownersCount = $dbForProject->count(
|
||||
collection: 'memberships',
|
||||
queries: [
|
||||
Query::contains('roles', ['owner']),
|
||||
Query::equal('teamInternalId', [$team->getSequence()])
|
||||
],
|
||||
max: 2
|
||||
);
|
||||
|
||||
// Is the role change being requested by the user on their own membership?
|
||||
$isCurrentUserAnOwner = $user->getSequence() === $membership->getAttribute('userInternalId');
|
||||
|
||||
// Prevent role change if there's only one owner left,
|
||||
// the requester is that owner, and the new `$roles` no longer include 'owner'
|
||||
if ($ownersCount === 1 && $isOwner && $isCurrentUserAnOwner && !\in_array('owner', $roles)) {
|
||||
throw new Exception(Exception::MEMBERSHIP_DOWNGRADE_PROHIBITED, 'There must be at least one owner in the organization.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isOwner && !$isPrivilegedUser && !$isAppUser) { // Not owner, not admin, not app (server)
|
||||
throw new Exception(Exception::USER_UNAUTHORIZED, 'User is not allowed to modify roles');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the roles
|
||||
*/
|
||||
$membership->setAttribute('roles', $roles);
|
||||
$membership = $dbForProject->updateDocument('memberships', $membership->getId(), $membership);
|
||||
|
||||
/**
|
||||
* Replace membership on profile
|
||||
*/
|
||||
$dbForProject->purgeCachedDocument('users', $profile->getId());
|
||||
|
||||
$queueForEvents
|
||||
->setParam('userId', $profile->getId())
|
||||
->setParam('teamId', $team->getId())
|
||||
->setParam('membershipId', $membership->getId());
|
||||
|
||||
$response->dynamic(
|
||||
$membership
|
||||
->setAttribute('teamName', $team->getAttribute('name'))
|
||||
->setAttribute('userName', $profile->getAttribute('name'))
|
||||
->setAttribute('userEmail', $profile->getAttribute('email')),
|
||||
Response::MODEL_MEMBERSHIP
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
|
||||
|
||||
use Appwrite\Auth\MFA\Type\TOTP;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Database\Validator\Queries\Memberships;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Order as OrderException;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Query\Cursor;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class XList extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'listTeamMemberships';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/teams/:teamId/memberships')
|
||||
->desc('List team memberships')
|
||||
->groups(['api', 'teams'])
|
||||
->label('scope', 'teams.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'memberships',
|
||||
name: 'listMemberships',
|
||||
description: '/docs/references/teams/list-team-members.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_MEMBERSHIP_LIST,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('queries', [], new Memberships(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Memberships::ALLOWED_ATTRIBUTES), true)
|
||||
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('authorization')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$queries = Query::parseQueries($queries);
|
||||
} catch (QueryException $e) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
|
||||
}
|
||||
|
||||
if (!empty($search)) {
|
||||
$queries[] = Query::search('search', $search);
|
||||
}
|
||||
|
||||
// Set internal queries
|
||||
$queries[] = Query::equal('teamInternalId', [$team->getSequence()]);
|
||||
|
||||
$cursor = Query::getCursorQueries($queries, false);
|
||||
$cursor = \reset($cursor);
|
||||
|
||||
if ($cursor !== false) {
|
||||
$validator = new Cursor();
|
||||
if (!$validator->isValid($cursor)) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
|
||||
}
|
||||
|
||||
$membershipId = $cursor->getValue();
|
||||
$cursorDocument = $dbForProject->getDocument('memberships', $membershipId);
|
||||
|
||||
if ($cursorDocument->isEmpty()) {
|
||||
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Membership '{$membershipId}' for the 'cursor' value not found.");
|
||||
}
|
||||
|
||||
$cursor->setValue($cursorDocument);
|
||||
}
|
||||
|
||||
$filterQueries = Query::groupByType($queries)['filters'];
|
||||
try {
|
||||
$memberships = $dbForProject->find(
|
||||
collection: 'memberships',
|
||||
queries: $queries,
|
||||
);
|
||||
$total = $includeTotal ? $dbForProject->count(
|
||||
collection: 'memberships',
|
||||
queries: $filterQueries,
|
||||
max: APP_LIMIT_COUNT
|
||||
) : 0;
|
||||
} catch (OrderException $e) {
|
||||
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
|
||||
}
|
||||
|
||||
|
||||
$memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId')));
|
||||
|
||||
$membershipsPrivacy = [
|
||||
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
|
||||
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
|
||||
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
|
||||
];
|
||||
|
||||
$roles = $authorization->getRoles();
|
||||
$isPrivilegedUser = User::isPrivileged($roles);
|
||||
$isAppUser = User::isApp($roles);
|
||||
|
||||
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
|
||||
return $privacy || $isPrivilegedUser || $isAppUser;
|
||||
}, $membershipsPrivacy);
|
||||
|
||||
$memberships = array_map(function ($membership) use ($dbForProject, $team, $membershipsPrivacy) {
|
||||
$user = !empty(array_filter($membershipsPrivacy))
|
||||
? $dbForProject->getDocument('users', $membership->getAttribute('userId'))
|
||||
: new Document();
|
||||
|
||||
if ($membershipsPrivacy['mfa']) {
|
||||
$mfa = $user->getAttribute('mfa', false);
|
||||
|
||||
if ($mfa) {
|
||||
$totp = TOTP::getAuthenticatorFromUser($user);
|
||||
$totpEnabled = $totp && $totp->getAttribute('verified', false);
|
||||
$emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false);
|
||||
$phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false);
|
||||
|
||||
if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) {
|
||||
$mfa = false;
|
||||
}
|
||||
}
|
||||
|
||||
$membership->setAttribute('mfa', $mfa);
|
||||
}
|
||||
|
||||
if ($membershipsPrivacy['userName']) {
|
||||
$membership->setAttribute('userName', $user->getAttribute('name'));
|
||||
}
|
||||
|
||||
if ($membershipsPrivacy['userEmail']) {
|
||||
$membership->setAttribute('userEmail', $user->getAttribute('email'));
|
||||
}
|
||||
|
||||
$membership->setAttribute('teamName', $team->getAttribute('name'));
|
||||
|
||||
return $membership;
|
||||
}, $memberships);
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'memberships' => $memberships,
|
||||
'total' => $total,
|
||||
]), Response::MODEL_MEMBERSHIP_LIST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Preferences;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Structure as StructureException;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
||||
class Get extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'getTeamPreferences';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/teams/:teamId/prefs')
|
||||
->desc('Get team preferences')
|
||||
->groups(['api', 'teams'])
|
||||
->label('scope', 'teams.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'teams',
|
||||
name: 'getPrefs',
|
||||
description: '/docs/references/teams/get-team-prefs.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PREFERENCES,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, Response $response, Database $dbForProject)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
$prefs = $team->getAttribute('prefs', []);
|
||||
|
||||
try {
|
||||
$prefs = new Document($prefs);
|
||||
} catch (StructureException $e) {
|
||||
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage());
|
||||
}
|
||||
|
||||
$response->dynamic($prefs, Response::MODEL_PREFERENCES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Preferences;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Structure as StructureException;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Assoc;
|
||||
|
||||
class Update extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'updateTeamPreferences';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
|
||||
->setHttpPath('/v1/teams/:teamId/prefs')
|
||||
->desc('Update team preferences')
|
||||
->groups(['api', 'teams'])
|
||||
->label('event', 'teams.[teamId].update.prefs')
|
||||
->label('scope', 'teams.write')
|
||||
->label('audits.event', 'team.update')
|
||||
->label('audits.resource', 'team/{response.$id}')
|
||||
->label('audits.userId', '{response.$id}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'teams',
|
||||
name: 'updatePrefs',
|
||||
description: '/docs/references/teams/update-team-prefs.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PREFERENCES,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('prefs', '', new Assoc(), 'Prefs key-value JSON object.')
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('queueForEvents')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, array $prefs, Response $response, Database $dbForProject, Event $queueForEvents)
|
||||
{
|
||||
try {
|
||||
$prefs = new Document($prefs);
|
||||
} catch (StructureException $e) {
|
||||
throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, $e->getMessage());
|
||||
}
|
||||
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
$team = $dbForProject->updateDocument('teams', $team->getId(), new Document([
|
||||
'prefs' => $prefs->getArrayCopy()
|
||||
]));
|
||||
|
||||
$queueForEvents->setParam('teamId', $team->getId());
|
||||
|
||||
$response->dynamic($prefs, Response::MODEL_PREFERENCES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Documents\User;
|
||||
use Appwrite\Utopia\Database\Validator\CustomId;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Duplicate;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Database\Validator\Key;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Create extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'createTeam';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
|
||||
->setHttpPath('/v1/teams')
|
||||
->desc('Create team')
|
||||
->groups(['api', 'teams'])
|
||||
->label('event', 'teams.[teamId].create')
|
||||
->label('scope', 'teams.write')
|
||||
->label('audits.event', 'team.create')
|
||||
->label('audits.resource', 'team/{response.$id}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'teams',
|
||||
name: 'create',
|
||||
description: '/docs/references/teams/create-team.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_CREATED,
|
||||
model: Response::MODEL_TEAM,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new CustomId(), 'Team ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
|
||||
->param('name', null, new Text(128), 'Team name. Max length: 128 chars.')
|
||||
->param('roles', ['owner'], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the roles in the team for the user who created it. The default role is **owner**. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', true)
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('authorization')
|
||||
->inject('queueForEvents')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
|
||||
{
|
||||
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
|
||||
$isAppUser = User::isApp($authorization->getRoles());
|
||||
|
||||
$teamId = $teamId == 'unique()' ? ID::unique() : $teamId;
|
||||
|
||||
try {
|
||||
$team = $authorization->skip(fn () => $dbForProject->createDocument('teams', new Document([
|
||||
'$id' => $teamId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::team($teamId)),
|
||||
Permission::update(Role::team($teamId, 'owner')),
|
||||
Permission::delete(Role::team($teamId, 'owner')),
|
||||
],
|
||||
'labels' => [],
|
||||
'name' => $name,
|
||||
'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1,
|
||||
'prefs' => new \stdClass(),
|
||||
'search' => implode(' ', [$teamId, $name]),
|
||||
])));
|
||||
} catch (Duplicate $th) {
|
||||
throw new Exception(Exception::TEAM_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
if (!$isPrivilegedUser && !$isAppUser) { // Don't add user on server mode
|
||||
if (!\in_array('owner', $roles)) {
|
||||
$roles[] = 'owner';
|
||||
}
|
||||
|
||||
$membershipId = ID::unique();
|
||||
$membership = new Document([
|
||||
'$id' => $membershipId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::user($user->getId())),
|
||||
Permission::read(Role::team($team->getId())),
|
||||
Permission::update(Role::user($user->getId())),
|
||||
Permission::update(Role::team($team->getId(), 'owner')),
|
||||
Permission::delete(Role::user($user->getId())),
|
||||
Permission::delete(Role::team($team->getId(), 'owner')),
|
||||
],
|
||||
'userId' => $user->getId(),
|
||||
'userInternalId' => $user->getSequence(),
|
||||
'teamId' => $team->getId(),
|
||||
'teamInternalId' => $team->getSequence(),
|
||||
'roles' => $roles,
|
||||
'invited' => DateTime::now(),
|
||||
'joined' => DateTime::now(),
|
||||
'confirm' => true,
|
||||
'secret' => '',
|
||||
'search' => implode(' ', [$membershipId, $user->getId()])
|
||||
]);
|
||||
|
||||
$membership = $dbForProject->createDocument('memberships', $membership);
|
||||
$dbForProject->purgeCachedDocument('users', $user->getId());
|
||||
}
|
||||
|
||||
$queueForEvents->setParam('teamId', $team->getId());
|
||||
|
||||
if (!empty($user->getId())) {
|
||||
$queueForEvents->setParam('userId', $user->getId());
|
||||
}
|
||||
|
||||
$response
|
||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||
->dynamic($team, Response::MODEL_TEAM);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
|
||||
|
||||
use Appwrite\Event\Delete as DeleteEvent;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\Platform\Workers\Deletes;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\ContentType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
||||
class Delete extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'deleteTeam';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
|
||||
->setHttpPath('/v1/teams/:teamId')
|
||||
->desc('Delete team')
|
||||
->groups(['api', 'teams'])
|
||||
->label('event', 'teams.[teamId].delete')
|
||||
->label('scope', 'teams.write')
|
||||
->label('audits.event', 'team.delete')
|
||||
->label('audits.resource', 'team/{request.teamId}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'teams',
|
||||
name: 'delete',
|
||||
description: '/docs/references/teams/delete-team.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_NOCONTENT,
|
||||
model: Response::MODEL_NONE,
|
||||
)
|
||||
],
|
||||
contentType: ContentType::NONE
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->inject('response')
|
||||
->inject('getProjectDB')
|
||||
->inject('dbForProject')
|
||||
->inject('queueForDeletes')
|
||||
->inject('queueForEvents')
|
||||
->inject('project')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, Response $response, callable $getProjectDB, Database $dbForProject, DeleteEvent $queueForDeletes, Event $queueForEvents, Document $project)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!$dbForProject->deleteDocument('teams', $teamId)) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove team from DB');
|
||||
}
|
||||
|
||||
// Sync delete
|
||||
$deletes = new Deletes();
|
||||
$deletes->deleteMemberships($getProjectDB, $team, $project);
|
||||
|
||||
// Async delete
|
||||
if ($project->getId() === 'console') {
|
||||
$queueForDeletes
|
||||
->setType(DELETE_TYPE_TEAM_PROJECTS)
|
||||
->setDocument($team)
|
||||
->trigger();
|
||||
}
|
||||
|
||||
$queueForDeletes
|
||||
->setType(DELETE_TYPE_DOCUMENT)
|
||||
->setDocument($team);
|
||||
|
||||
$queueForEvents
|
||||
->setParam('teamId', $team->getId())
|
||||
->setPayload($response->output($team, Response::MODEL_TEAM))
|
||||
;
|
||||
|
||||
$response->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
|
||||
class Get extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'getTeam';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/teams/:teamId')
|
||||
->desc('Get team')
|
||||
->groups(['api', 'teams'])
|
||||
->label('scope', 'teams.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'teams',
|
||||
name: 'get',
|
||||
description: '/docs/references/teams/get-team.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_TEAM,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, Response $response, Database $dbForProject)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
$response->dynamic($team, Response::MODEL_TEAM);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Teams\Name;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Validator\UID;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class Update extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'updateTeamName';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT)
|
||||
->setHttpPath('/v1/teams/:teamId')
|
||||
->desc('Update name')
|
||||
->groups(['api', 'teams'])
|
||||
->label('event', 'teams.[teamId].update')
|
||||
->label('scope', 'teams.write')
|
||||
->label('audits.event', 'team.update')
|
||||
->label('audits.resource', 'team/{response.$id}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'teams',
|
||||
name: 'updateName',
|
||||
description: '/docs/references/teams/update-team-name.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_TEAM,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('teamId', '', new UID(), 'Team ID.')
|
||||
->param('name', null, new Text(128), 'New team name. Max length: 128 chars.')
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->inject('queueForEvents')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $teamId, string $name, Response $response, Database $dbForProject, Event $queueForEvents)
|
||||
{
|
||||
$team = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($team->isEmpty()) {
|
||||
throw new Exception(Exception::TEAM_NOT_FOUND);
|
||||
}
|
||||
|
||||
$team
|
||||
->setAttribute('name', $name)
|
||||
->setAttribute('search', implode(' ', [$teamId, $name]));
|
||||
|
||||
$team = $dbForProject->updateDocument('teams', $team->getId(), $team);
|
||||
|
||||
$queueForEvents->setParam('teamId', $team->getId());
|
||||
|
||||
$response->dynamic($team, Response::MODEL_TEAM);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Http\Teams;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Database\Validator\Queries\Teams;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Order as OrderException;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Query\Cursor;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Text;
|
||||
|
||||
class XList extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'listTeams';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/teams')
|
||||
->desc('List teams')
|
||||
->groups(['api', 'teams'])
|
||||
->label('scope', 'teams.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'teams',
|
||||
group: 'teams',
|
||||
name: 'list',
|
||||
description: '/docs/references/teams/list-teams.md',
|
||||
auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_TEAM_LIST,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('queries', [], new Teams(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Teams::ALLOWED_ATTRIBUTES), true)
|
||||
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->inject('response')
|
||||
->inject('dbForProject')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject)
|
||||
{
|
||||
try {
|
||||
$queries = Query::parseQueries($queries);
|
||||
} catch (QueryException $e) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
|
||||
}
|
||||
|
||||
if (!empty($search)) {
|
||||
$queries[] = Query::search('search', $search);
|
||||
}
|
||||
|
||||
$cursor = Query::getCursorQueries($queries, false);
|
||||
$cursor = \reset($cursor);
|
||||
|
||||
if ($cursor !== false) {
|
||||
$validator = new Cursor();
|
||||
if (!$validator->isValid($cursor)) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
|
||||
}
|
||||
|
||||
$teamId = $cursor->getValue();
|
||||
$cursorDocument = $dbForProject->getDocument('teams', $teamId);
|
||||
|
||||
if ($cursorDocument->isEmpty()) {
|
||||
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Team '{$teamId}' for the 'cursor' value not found.");
|
||||
}
|
||||
|
||||
$cursor->setValue($cursorDocument);
|
||||
}
|
||||
|
||||
$filterQueries = Query::groupByType($queries)['filters'];
|
||||
try {
|
||||
$results = $dbForProject->find('teams', $queries);
|
||||
$total = $includeTotal ? $dbForProject->count('teams', $filterQueries, APP_LIMIT_COUNT) : 0;
|
||||
} catch (OrderException $e) {
|
||||
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
|
||||
}
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'teams' => $results,
|
||||
'total' => $total,
|
||||
]), Response::MODEL_TEAM_LIST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams;
|
||||
|
||||
use Appwrite\Platform\Modules\Teams\Services\Http;
|
||||
use Utopia\Platform;
|
||||
|
||||
class Module extends Platform\Module
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->addService('http', new Http());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Teams\Services;
|
||||
|
||||
use Appwrite\Platform\Modules\Teams\Http\Logs\XList as ListLogs;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Memberships\Create as CreateMembership;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Memberships\Delete as DeleteMembership;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Memberships\Get as GetMembership;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Memberships\Status\Update as UpdateMembershipStatus;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Memberships\Update as UpdateMembership;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Memberships\XList as ListMemberships;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Preferences\Get as GetPreferences;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Preferences\Update as UpdatePreferences;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Teams\Create as CreateTeam;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Teams\Delete as DeleteTeam;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Teams\Get as GetTeam;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Teams\Name\Update as UpdateTeamName;
|
||||
use Appwrite\Platform\Modules\Teams\Http\Teams\XList as ListTeams;
|
||||
use Utopia\Platform\Service;
|
||||
|
||||
class Http extends Service
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->type = Service::TYPE_HTTP;
|
||||
|
||||
// Teams
|
||||
$this->addAction(CreateTeam::getName(), new CreateTeam());
|
||||
$this->addAction(GetTeam::getName(), new GetTeam());
|
||||
$this->addAction(ListTeams::getName(), new ListTeams());
|
||||
$this->addAction(DeleteTeam::getName(), new DeleteTeam());
|
||||
$this->addAction(UpdateTeamName::getName(), new UpdateTeamName());
|
||||
|
||||
// Preferences
|
||||
$this->addAction(GetPreferences::getName(), new GetPreferences());
|
||||
$this->addAction(UpdatePreferences::getName(), new UpdatePreferences());
|
||||
|
||||
// Memberships
|
||||
$this->addAction(CreateMembership::getName(), new CreateMembership());
|
||||
$this->addAction(GetMembership::getName(), new GetMembership());
|
||||
$this->addAction(ListMemberships::getName(), new ListMemberships());
|
||||
$this->addAction(UpdateMembership::getName(), new UpdateMembership());
|
||||
$this->addAction(DeleteMembership::getName(), new DeleteMembership());
|
||||
$this->addAction(UpdateMembershipStatus::getName(), new UpdateMembershipStatus());
|
||||
|
||||
// Logs
|
||||
$this->addAction(ListLogs::getName(), new ListLogs());
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,12 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Callback;
|
||||
|
||||
use Appwrite\Auth\OAuth2\Github as OAuth2Github;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Platform\Permission as AppwritePermission;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\DateTime;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
@@ -21,6 +20,7 @@ use Utopia\VCS\Adapter\Git\GitHub;
|
||||
class Get extends Action
|
||||
{
|
||||
use HTTP;
|
||||
use AppwritePermission;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
@@ -132,13 +132,7 @@ class Get extends Action
|
||||
|
||||
$installation = new Document([
|
||||
'$id' => ID::unique(),
|
||||
'$permissions' => [
|
||||
Permission::read(Role::team(ID::custom($teamId))),
|
||||
Permission::update(Role::team(ID::custom($teamId), 'owner')),
|
||||
Permission::update(Role::team(ID::custom($teamId), 'developer')),
|
||||
Permission::delete(Role::team(ID::custom($teamId), 'owner')),
|
||||
Permission::delete(Role::team(ID::custom($teamId), 'developer')),
|
||||
],
|
||||
'$permissions' => $this->getPermissions($teamId, $projectId),
|
||||
'providerInstallationId' => $providerInstallationId,
|
||||
'projectId' => $projectId,
|
||||
'projectInternalId' => $projectInternalId,
|
||||
|
||||
@@ -152,6 +152,8 @@ class Create extends Action
|
||||
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
|
||||
$repository['organization'] = $installation->getAttribute('organization', '');
|
||||
$repository['provider'] = $installation->getAttribute('provider', '');
|
||||
$repository['providerInstallationId'] = $installation->getAttribute('providerInstallationId', '');
|
||||
$repository['authorized'] = true;
|
||||
|
||||
$response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY);
|
||||
}
|
||||
|
||||
@@ -85,11 +85,23 @@ class Get extends Action
|
||||
|
||||
$repository = $github->getRepository($owner, $repositoryName);
|
||||
|
||||
$authorized = false;
|
||||
try {
|
||||
$installationRepository = $github->getInstallationRepository($repositoryName);
|
||||
if (!empty($installationRepository)) {
|
||||
$authorized = true;
|
||||
}
|
||||
} catch (RepositoryNotFound $e) {
|
||||
$authorized = false;
|
||||
}
|
||||
|
||||
$repository['id'] = \strval($repository['id']) ?? '';
|
||||
$repository['pushedAt'] = $repository['pushed_at'] ?? '';
|
||||
$repository['organization'] = $installation->getAttribute('organization', '');
|
||||
$repository['provider'] = $installation->getAttribute('provider', '');
|
||||
$repository['defaultBranch'] = $repository['default_branch'] ?? '';
|
||||
$repository['authorized'] = $authorized;
|
||||
$repository['providerInstallationId'] = $providerInstallationId;
|
||||
|
||||
$response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY);
|
||||
}
|
||||
|
||||
@@ -141,13 +141,15 @@ class XList extends Action
|
||||
|
||||
$page = ($offset / $limit) + 1;
|
||||
$owner = $github->getOwnerName($providerInstallationId);
|
||||
['items' => $repos, 'total' => $total] = $github->searchRepositories($owner, $page, $limit, $search);
|
||||
['items' => $repos, 'total' => $total] = $github->searchRepositories($providerInstallationId, $owner, $page, $limit, $search);
|
||||
|
||||
$repos = \array_map(function ($repo) use ($installation) {
|
||||
$repo['id'] = \strval($repo['id'] ?? '');
|
||||
$repo['pushedAt'] = $repo['pushed_at'] ?? null;
|
||||
$repo['provider'] = $installation->getAttribute('provider', '') ?? '';
|
||||
$repo['organization'] = $installation->getAttribute('organization', '') ?? '';
|
||||
$repo['providerInstallationId'] = $installation->getAttribute('providerInstallationId', '');
|
||||
$repo['authorized'] = true;
|
||||
return $repo;
|
||||
}, $repos);
|
||||
|
||||
|
||||
@@ -805,6 +805,16 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
->setExcludePaths([
|
||||
'.github/workflows/**',
|
||||
'.github/ISSUE_TEMPLATE/**',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'install.sh',
|
||||
'install.ps1',
|
||||
'README.md',
|
||||
'CHANGELOG.md',
|
||||
'LICENSE',
|
||||
'LICENSE.md',
|
||||
'scoop/*.json',
|
||||
'lib/constants.ts',
|
||||
])
|
||||
->setMaxDiffLines(500)
|
||||
->setUserId('sdk-analyst');
|
||||
|
||||
@@ -11,7 +11,6 @@ use Appwrite\Event\StatsUsage;
|
||||
use Appwrite\Event\Webhook;
|
||||
use Appwrite\Extend\Exception as AppwriteException;
|
||||
use Appwrite\Utopia\Response\Model\Execution;
|
||||
use Exception;
|
||||
use Executor\Executor;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Console;
|
||||
@@ -73,7 +72,10 @@ class Functions extends Action
|
||||
$payload = $message->getPayload() ?? [];
|
||||
|
||||
if (empty($payload)) {
|
||||
throw new Exception('Missing payload');
|
||||
throw new AppwriteException(
|
||||
AppwriteException::GENERAL_ARGUMENT_INVALID,
|
||||
'Functions worker: missing payload in schedule execution'
|
||||
);
|
||||
}
|
||||
|
||||
$type = $payload['type'] ?? '';
|
||||
@@ -392,7 +394,10 @@ class Functions extends Action
|
||||
$runtimes = Config::getParam($version === 'v2' ? 'runtimes-v2' : 'runtimes', []);
|
||||
|
||||
if (!\array_key_exists($function->getAttribute('runtime'), $runtimes)) {
|
||||
throw new Exception('Runtime "' . $function->getAttribute('runtime', '') . '" is not supported');
|
||||
throw new AppwriteException(
|
||||
AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED,
|
||||
\sprintf('Runtime "%s" is not supported', $function->getAttribute('runtime', '')),
|
||||
);
|
||||
}
|
||||
|
||||
$runtime = $runtimes[$function->getAttribute('runtime')];
|
||||
@@ -640,7 +645,7 @@ class Functions extends Action
|
||||
if (!empty($error)) {
|
||||
throw new AppwriteException(
|
||||
AppwriteException::GENERAL_SERVER_ERROR,
|
||||
$error ?: 'Function execution failed with no error message',
|
||||
'Function execution failed: ' . ($error ?: 'No error message provided'),
|
||||
$errorCode
|
||||
);
|
||||
}
|
||||
|
||||
@@ -313,6 +313,8 @@ class Migrations extends Action
|
||||
'files.write',
|
||||
'functions.read',
|
||||
'functions.write',
|
||||
'sites.read',
|
||||
'sites.write',
|
||||
'tokens.read',
|
||||
'tokens.write',
|
||||
]
|
||||
|
||||
@@ -453,6 +453,38 @@ abstract class Format
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'migrations':
|
||||
switch ($method) {
|
||||
case 'createAppwriteMigration':
|
||||
case 'getAppwriteReport':
|
||||
switch ($param) {
|
||||
case 'resources':
|
||||
return 'AppwriteMigrationResource';
|
||||
}
|
||||
break;
|
||||
case 'createFirebaseMigration':
|
||||
case 'getFirebaseReport':
|
||||
switch ($param) {
|
||||
case 'resources':
|
||||
return 'FirebaseMigrationResource';
|
||||
}
|
||||
break;
|
||||
case 'createSupabaseMigration':
|
||||
case 'getSupabaseReport':
|
||||
switch ($param) {
|
||||
case 'resources':
|
||||
return 'SupabaseMigrationResource';
|
||||
}
|
||||
break;
|
||||
case 'createNHostMigration':
|
||||
case 'getNHostReport':
|
||||
switch ($param) {
|
||||
case 'resources':
|
||||
return 'NHostMigrationResource';
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'project':
|
||||
switch ($method) {
|
||||
case 'getUsage':
|
||||
|
||||
@@ -53,6 +53,12 @@ class MigrationReport extends Model
|
||||
'default' => 0,
|
||||
'example' => 20,
|
||||
])
|
||||
->addRule(Resource::TYPE_SITE, [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Number of sites to be migrated.',
|
||||
'default' => 0,
|
||||
'example' => 5,
|
||||
])
|
||||
->addRule('size', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Size of files to be migrated in mb.',
|
||||
|
||||
@@ -47,6 +47,18 @@ class ProviderRepository extends Model
|
||||
'default' => '',
|
||||
'example' => 'main',
|
||||
])
|
||||
->addRule('providerInstallationId', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'VCS (Version Control System) installation ID.',
|
||||
'default' => '',
|
||||
'example' => '108104697',
|
||||
])
|
||||
->addRule('authorized', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Is VCS (Version Control System) repository authorized for the installation?',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
])
|
||||
->addRule('pushedAt', [
|
||||
'type' => self::TYPE_DATETIME,
|
||||
'description' => 'Last commit date in ISO 8601 format.',
|
||||
|
||||
@@ -3267,6 +3267,205 @@ trait DatabasesBase
|
||||
], $this->getHeaders()));
|
||||
}
|
||||
|
||||
public function testListDocumentsWithCache(): void
|
||||
{
|
||||
$data = $this->setupDocuments();
|
||||
$databaseId = $data['databaseId'];
|
||||
$docIds = $data['documentIds'];
|
||||
|
||||
// Filter to setup documents only, since other tests may have created additional docs in this collection.
|
||||
$baseQueries = [
|
||||
Query::equal('$id', $docIds)->toString(),
|
||||
Query::select(['title', 'releaseYear', '$id'])->toString(),
|
||||
Query::orderAsc('releaseYear')->toString(),
|
||||
];
|
||||
|
||||
// 1. Using cache with select queries, first request should miss cache.
|
||||
$documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => $baseQueries,
|
||||
'ttl' => 30,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents1['headers']['status-code']);
|
||||
$this->assertEquals(3, $documents1['body']['total']);
|
||||
$this->assertCount(3, $documents1['body'][$this->getRecordResource()]);
|
||||
$this->assertEquals(1944, $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
|
||||
$this->assertEquals(2017, $documents1['body'][$this->getRecordResource()][1]['releaseYear']);
|
||||
$this->assertEquals(2019, $documents1['body'][$this->getRecordResource()][2]['releaseYear']);
|
||||
$this->assertArrayHasKey('title', $documents1['body'][$this->getRecordResource()][0]);
|
||||
$this->assertArrayHasKey('releaseYear', $documents1['body'][$this->getRecordResource()][0]);
|
||||
$this->assertArrayHasKey('$id', $documents1['body'][$this->getRecordResource()][0]);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
|
||||
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
|
||||
|
||||
// 2. Using cache with same select queries, should return cached results.
|
||||
$documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => $baseQueries,
|
||||
'ttl' => 30,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents2['headers']['status-code']);
|
||||
$this->assertEquals(3, $documents2['body']['total']);
|
||||
$this->assertCount(3, $documents2['body'][$this->getRecordResource()]);
|
||||
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']);
|
||||
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']);
|
||||
$this->assertEquals($documents1['body'][$this->getRecordResource()][0]['releaseYear'], $documents2['body'][$this->getRecordResource()][0]['releaseYear']);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
|
||||
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
|
||||
|
||||
// 3. Using cache with same select queries but total is false, should return cached results just for documents.
|
||||
$documents3 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => $baseQueries,
|
||||
'ttl' => 30,
|
||||
'total' => false,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents3['headers']['status-code']);
|
||||
$this->assertCount(3, $documents3['body'][$this->getRecordResource()]);
|
||||
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['$id'], $documents1['body'][$this->getRecordResource()][0]['$id']);
|
||||
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
|
||||
$this->assertEquals($documents3['body'][$this->getRecordResource()][0]['releaseYear'], $documents1['body'][$this->getRecordResource()][0]['releaseYear']);
|
||||
$this->assertEquals(0, $documents3['body']['total']);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
|
||||
$this->assertEquals('hit', $documents3['headers']['x-appwrite-cache']);
|
||||
|
||||
// 4. Using cache with different select queries, should miss cache.
|
||||
$documents4 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => [
|
||||
Query::equal('$id', $docIds)->toString(),
|
||||
Query::select(['title'])->toString(),
|
||||
Query::orderAsc('releaseYear')->toString(),
|
||||
],
|
||||
'ttl' => 10,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents4['headers']['status-code']);
|
||||
$this->assertEquals(3, $documents4['body']['total']);
|
||||
$this->assertCount(3, $documents4['body'][$this->getRecordResource()]);
|
||||
$this->assertEquals($documents4['body'][$this->getRecordResource()][0]['title'], $documents1['body'][$this->getRecordResource()][0]['title']);
|
||||
$this->assertEquals($documents4['body'][$this->getRecordResource()][1]['title'], $documents1['body'][$this->getRecordResource()][1]['title']);
|
||||
$this->assertEquals($documents4['body'][$this->getRecordResource()][2]['title'], $documents1['body'][$this->getRecordResource()][2]['title']);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents4['headers']);
|
||||
$this->assertEquals('miss', $documents4['headers']['x-appwrite-cache']);
|
||||
|
||||
// 5. Not using cache at all
|
||||
$documents5 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => [
|
||||
Query::equal('$id', $docIds)->toString(),
|
||||
Query::select(['title', 'releaseYear', '$id'])->toString(),
|
||||
Query::orderAsc('releaseYear')->toString(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents5['headers']['status-code']);
|
||||
$this->assertCount(3, $documents5['body'][$this->getRecordResource()]);
|
||||
$this->assertEquals(1944, $documents5['body'][$this->getRecordResource()][0]['releaseYear']);
|
||||
$this->assertArrayNotHasKey('x-appwrite-cache', $documents5['headers']);
|
||||
|
||||
sleep(10);
|
||||
|
||||
// 6. Using cache with same select queries but passed ttl time, should miss cache.
|
||||
$documents6 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => $baseQueries,
|
||||
'ttl' => 10,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents6['headers']['status-code']);
|
||||
$this->assertCount(3, $documents6['body'][$this->getRecordResource()]);
|
||||
$this->assertArrayHasKey('title', $documents6['body'][$this->getRecordResource()][0]);
|
||||
$this->assertArrayHasKey('releaseYear', $documents6['body'][$this->getRecordResource()][0]);
|
||||
$this->assertArrayHasKey('$id', $documents6['body'][$this->getRecordResource()][0]);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents6['headers']);
|
||||
$this->assertEquals('miss', $documents6['headers']['x-appwrite-cache']);
|
||||
}
|
||||
|
||||
public function testListDocumentsCacheBustedByAttributeChange(): void
|
||||
{
|
||||
$data = $this->setupDocuments();
|
||||
$databaseId = $data['databaseId'];
|
||||
$docIds = $data['documentIds'];
|
||||
|
||||
// Use different select queries from testListDocumentsWithCache to avoid cache key collision.
|
||||
$queries = [
|
||||
Query::equal('$id', $docIds)->toString(),
|
||||
Query::select(['title', '$id'])->toString(),
|
||||
Query::orderAsc('$createdAt')->toString(),
|
||||
];
|
||||
|
||||
// 1. First request should miss cache.
|
||||
$documents1 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => $queries,
|
||||
'ttl' => 300,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents1['headers']['status-code']);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents1['headers']);
|
||||
$this->assertEquals('miss', $documents1['headers']['x-appwrite-cache']);
|
||||
|
||||
// 2. Same request should hit cache.
|
||||
$documents2 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => $queries,
|
||||
'ttl' => 300,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents2['headers']['status-code']);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents2['headers']);
|
||||
$this->assertEquals('hit', $documents2['headers']['x-appwrite-cache']);
|
||||
|
||||
// 3. Add a new attribute to the collection, which updates the collection's $updatedAt.
|
||||
$attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey']
|
||||
]), [
|
||||
'key' => 'cacheTestAttr',
|
||||
'size' => 64,
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $attribute['headers']['status-code']);
|
||||
|
||||
// Wait for the attribute to be ready
|
||||
$this->waitForAttribute($databaseId, $data['moviesId'], 'cacheTestAttr');
|
||||
|
||||
// 4. Same request should now miss cache because collection $updatedAt changed.
|
||||
$documents3 = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'queries' => $queries,
|
||||
'ttl' => 300,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $documents3['headers']['status-code']);
|
||||
$this->assertArrayHasKey('x-appwrite-cache', $documents3['headers']);
|
||||
$this->assertEquals('miss', $documents3['headers']['x-appwrite-cache']);
|
||||
}
|
||||
|
||||
public function testGetDocument(): void
|
||||
{
|
||||
$data = $this->getDocumentsList();
|
||||
|
||||
@@ -566,6 +566,21 @@ class DatabasesStringTypesTest extends Scope
|
||||
$databaseId = $data['databaseId'];
|
||||
$collectionId = $data['collectionId'];
|
||||
|
||||
$encryptedVarchar = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/varchar', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], [
|
||||
'key' => 'varchar_encrypted_collection',
|
||||
'size' => 256,
|
||||
'required' => false,
|
||||
'encrypt' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $encryptedVarchar['headers']['status-code']);
|
||||
|
||||
$this->waitForAllAttributes($databaseId, $collectionId);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
@@ -581,6 +596,27 @@ class DatabasesStringTypesTest extends Scope
|
||||
$this->assertContains('text', $types);
|
||||
$this->assertContains('mediumtext', $types);
|
||||
$this->assertContains('longtext', $types);
|
||||
|
||||
// Ensure encrypt flag is present and boolean for all string-like types
|
||||
$attributesByKey = [];
|
||||
foreach ($attributes as $attribute) {
|
||||
$attributesByKey[$attribute['key']] = $attribute;
|
||||
}
|
||||
|
||||
$this->assertArrayHasKey('varchar_field', $attributesByKey);
|
||||
$this->assertFalse($attributesByKey['varchar_field']['encrypt']);
|
||||
|
||||
$this->assertArrayHasKey('text_field', $attributesByKey);
|
||||
$this->assertFalse($attributesByKey['text_field']['encrypt']);
|
||||
|
||||
$this->assertArrayHasKey('mediumtext_field', $attributesByKey);
|
||||
$this->assertFalse($attributesByKey['mediumtext_field']['encrypt']);
|
||||
|
||||
$this->assertArrayHasKey('longtext_field', $attributesByKey);
|
||||
$this->assertFalse($attributesByKey['longtext_field']['encrypt']);
|
||||
|
||||
$this->assertArrayHasKey('varchar_encrypted_collection', $attributesByKey);
|
||||
$this->assertTrue($attributesByKey['varchar_encrypted_collection']['encrypt']);
|
||||
}
|
||||
|
||||
public function testUpdateVarcharAttribute(): void
|
||||
|
||||
@@ -7,6 +7,7 @@ use Tests\E2E\Client;
|
||||
use Tests\E2E\General\UsageTest;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Services\Functions\FunctionsBase;
|
||||
use Utopia\Console;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Helpers\Permission;
|
||||
use Utopia\Database\Helpers\Role;
|
||||
@@ -1017,6 +1018,158 @@ trait MigrationsBase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites
|
||||
*/
|
||||
public function testAppwriteMigrationSite(): void
|
||||
{
|
||||
$site = $this->client->call(Client::METHOD_POST, '/sites', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], [
|
||||
'siteId' => ID::unique(),
|
||||
'name' => 'Test Site',
|
||||
'framework' => 'other',
|
||||
'buildRuntime' => 'node-22',
|
||||
'adapter' => 'static',
|
||||
'outputDirectory' => './',
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $site['headers']['status-code'], 'Create site failed: ' . json_encode($site['body'], JSON_PRETTY_PRINT));
|
||||
$this->assertNotEmpty($site['body']['$id']);
|
||||
|
||||
$siteId = $site['body']['$id'];
|
||||
|
||||
// Create deployment
|
||||
$deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [
|
||||
'content-type' => 'multipart/form-data',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], [
|
||||
'code' => $this->packageSite('static'),
|
||||
'activate' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $deployment['headers']['status-code']);
|
||||
$this->assertNotEmpty($deployment['body']['$id']);
|
||||
|
||||
$deploymentId = $deployment['body']['$id'];
|
||||
|
||||
// Wait for deployment to be ready
|
||||
$this->assertEventually(function () use ($siteId, $deploymentId) {
|
||||
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertEquals('ready', $response['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($response['body'], JSON_PRETTY_PRINT));
|
||||
}, 300000, 500);
|
||||
|
||||
// Create environment variable
|
||||
$variable = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/variables', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], [
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $variable['headers']['status-code']);
|
||||
|
||||
// Perform migration
|
||||
$result = $this->performMigrationSync([
|
||||
'resources' => [
|
||||
Resource::TYPE_SITE,
|
||||
Resource::TYPE_SITE_DEPLOYMENT,
|
||||
Resource::TYPE_SITE_VARIABLE,
|
||||
],
|
||||
'endpoint' => $this->webEndpoint,
|
||||
'projectId' => $this->getProject()['$id'],
|
||||
'apiKey' => $this->getProject()['apiKey'],
|
||||
]);
|
||||
|
||||
$this->assertEquals('completed', $result['status']);
|
||||
$this->assertEquals([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE], $result['resources']);
|
||||
|
||||
foreach ([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE] as $resource) {
|
||||
$this->assertArrayHasKey($resource, $result['statusCounters']);
|
||||
$this->assertEquals(0, $result['statusCounters'][$resource]['error']);
|
||||
$this->assertEquals(0, $result['statusCounters'][$resource]['pending']);
|
||||
$this->assertEquals(1, $result['statusCounters'][$resource]['success']);
|
||||
$this->assertEquals(0, $result['statusCounters'][$resource]['processing']);
|
||||
$this->assertEquals(0, $result['statusCounters'][$resource]['warning']);
|
||||
}
|
||||
|
||||
// Verify site in destination
|
||||
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getDestinationProject()['$id'],
|
||||
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']);
|
||||
$this->assertEquals($siteId, $response['body']['$id']);
|
||||
$this->assertEquals('Test Site', $response['body']['name']);
|
||||
$this->assertEquals('node-22', $response['body']['buildRuntime']);
|
||||
$this->assertEquals('other', $response['body']['framework']);
|
||||
$this->assertEquals('static', $response['body']['adapter']);
|
||||
|
||||
// Verify deployment in destination
|
||||
$this->assertEventually(function () use ($siteId) {
|
||||
$deployments = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getDestinationProject()['$id'],
|
||||
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $deployments['headers']['status-code']);
|
||||
$this->assertNotEmpty($deployments['body']);
|
||||
$this->assertEquals(1, $deployments['body']['total']);
|
||||
$this->assertEquals('ready', $deployments['body']['deployments'][0]['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployments['body']['deployments'][0], JSON_PRETTY_PRINT));
|
||||
}, 100000, 500);
|
||||
|
||||
// Verify variable in destination
|
||||
$variables = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/variables', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getDestinationProject()['$id'],
|
||||
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $variables['headers']['status-code']);
|
||||
$this->assertEquals(1, $variables['body']['total']);
|
||||
$this->assertEquals('TEST_VAR', $variables['body']['variables'][0]['key']);
|
||||
|
||||
// Cleanup
|
||||
$this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]);
|
||||
|
||||
$this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getDestinationProject()['$id'],
|
||||
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function packageSite(string $site): CURLFile
|
||||
{
|
||||
$stdout = '';
|
||||
$stderr = '';
|
||||
$folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site";
|
||||
$tarPath = "$folderPath/code.tar.gz";
|
||||
|
||||
Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr);
|
||||
|
||||
return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Import documents from a CSV file.
|
||||
*/
|
||||
|
||||
@@ -131,4 +131,23 @@ trait RealtimeBase
|
||||
$this->expectException(ConnectionException::class); // Check if server disconnected client
|
||||
$client->close();
|
||||
}
|
||||
|
||||
public function testConnectionRegionCheck(): void
|
||||
{
|
||||
/**
|
||||
* Test for SUCCESS
|
||||
* A project whose region matches the server region should connect successfully.
|
||||
*/
|
||||
$client = $this->getWebsocket(['documents']);
|
||||
$response = json_decode($client->receive(), true);
|
||||
|
||||
$this->assertArrayHasKey('type', $response);
|
||||
$this->assertArrayHasKey('data', $response);
|
||||
$this->assertEquals('connected', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('channels', $response['data']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
|
||||
$client->close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertNotEmpty($response['data']['user']);
|
||||
$this->assertCount(16, $response['data']['channels']);
|
||||
$this->assertIsList($response['data']['channels']);
|
||||
$this->assertTrue(array_is_list($response['data']['channels']));
|
||||
$this->assertContains('account', $response['data']['channels']);
|
||||
$this->assertContains('account.' . $userId, $response['data']['channels']);
|
||||
$this->assertContains('files', $response['data']['channels']);
|
||||
@@ -818,7 +820,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']);
|
||||
$this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']);
|
||||
@@ -863,7 +865,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
|
||||
@@ -919,7 +921,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
|
||||
@@ -975,7 +977,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
|
||||
@@ -1007,7 +1009,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']);
|
||||
@@ -1056,7 +1058,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
|
||||
@@ -1084,7 +1086,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
|
||||
@@ -1112,7 +1114,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']);
|
||||
@@ -1149,7 +1151,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
|
||||
@@ -1178,7 +1180,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
|
||||
@@ -1207,7 +1209,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']);
|
||||
@@ -1254,7 +1256,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
|
||||
@@ -1433,7 +1435,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response1['type']);
|
||||
$this->assertNotEmpty($response1['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response1['data']);
|
||||
$this->assertCount(6, $response1['data']['channels']);
|
||||
$this->assertCount(8, $response1['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']);
|
||||
@@ -1464,7 +1466,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response2['type']);
|
||||
$this->assertNotEmpty($response2['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response2['data']);
|
||||
$this->assertCount(6, $response2['data']['channels']);
|
||||
$this->assertCount(8, $response2['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']);
|
||||
@@ -1514,7 +1516,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response1['type']);
|
||||
$this->assertNotEmpty($response1['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response1['data']);
|
||||
$this->assertCount(6, $response1['data']['channels']);
|
||||
$this->assertCount(8, $response1['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
|
||||
@@ -1568,7 +1570,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response2['type']);
|
||||
$this->assertNotEmpty($response2['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response2['data']);
|
||||
$this->assertCount(6, $response2['data']['channels']);
|
||||
$this->assertCount(8, $response2['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
|
||||
@@ -1621,7 +1623,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response1['type']);
|
||||
$this->assertNotEmpty($response1['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response1['data']);
|
||||
$this->assertCount(6, $response1['data']['channels']);
|
||||
$this->assertCount(8, $response1['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']);
|
||||
@@ -1648,7 +1650,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response2['type']);
|
||||
$this->assertNotEmpty($response2['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response2['data']);
|
||||
$this->assertCount(6, $response2['data']['channels']);
|
||||
$this->assertCount(8, $response2['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']);
|
||||
@@ -1687,7 +1689,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response1['type']);
|
||||
$this->assertNotEmpty($response1['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response1['data']);
|
||||
$this->assertCount(6, $response1['data']['channels']);
|
||||
$this->assertCount(8, $response1['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']);
|
||||
@@ -1718,7 +1720,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response2['type']);
|
||||
$this->assertNotEmpty($response2['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response2['data']);
|
||||
$this->assertCount(6, $response2['data']['channels']);
|
||||
$this->assertCount(8, $response2['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']);
|
||||
@@ -1771,7 +1773,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
|
||||
@@ -1809,7 +1811,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']);
|
||||
$this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']);
|
||||
@@ -1951,7 +1953,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
|
||||
@@ -1990,7 +1992,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
|
||||
@@ -2040,7 +2042,7 @@ class RealtimeCustomClientTest extends Scope
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
$this->assertCount(6, $response['data']['channels']);
|
||||
$this->assertCount(8, $response['data']['channels']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']);
|
||||
@@ -2559,6 +2561,512 @@ class RealtimeCustomClientTest extends Scope
|
||||
$client->close();
|
||||
}
|
||||
|
||||
public function testChannelsTablesDB()
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$session = $user['session'] ?? '';
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
/**
|
||||
* Create a shared TablesDB database using the /tablesdb API.
|
||||
* This database will then be accessed via both /databases and /tablesdb routes.
|
||||
*/
|
||||
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]), [
|
||||
'databaseId' => ID::unique(),
|
||||
'name' => 'TablesDB Cross API Realtime DB',
|
||||
]);
|
||||
|
||||
$databaseId = $database['body']['$id'];
|
||||
|
||||
$this->assertEquals(201, $database['headers']['status-code']);
|
||||
|
||||
/**
|
||||
* Legacy collection in the shared database (/databases API).
|
||||
*/
|
||||
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]), [
|
||||
'collectionId' => ID::unique(),
|
||||
'name' => 'Legacy Actors',
|
||||
'permissions' => [
|
||||
Permission::create(Role::user($user['$id'])),
|
||||
],
|
||||
'documentSecurity' => true,
|
||||
]);
|
||||
|
||||
$collectionId = $collection['body']['$id'];
|
||||
|
||||
$attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]), [
|
||||
'key' => 'name',
|
||||
'size' => 256,
|
||||
'required' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $attribute['headers']['status-code']);
|
||||
|
||||
$this->assertEventually(function () use ($databaseId, $collectionId) {
|
||||
$attribute = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]));
|
||||
|
||||
$this->assertEquals('available', $attribute['body']['status']);
|
||||
}, 30000, 250);
|
||||
|
||||
/**
|
||||
* TablesDB table in the same database (/tablesdb API).
|
||||
*/
|
||||
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'tableId' => ID::unique(),
|
||||
'name' => 'Actors',
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::create(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]);
|
||||
|
||||
$tableId = $table['body']['$id'];
|
||||
|
||||
$column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'key' => 'name',
|
||||
'size' => 256,
|
||||
'required' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $column['headers']['status-code']);
|
||||
|
||||
$this->assertEventually(function () use ($databaseId, $tableId) {
|
||||
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()));
|
||||
|
||||
$this->assertEquals(200, $column['headers']['status-code']);
|
||||
$this->assertEquals('available', $column['body']['status']);
|
||||
}, 120000, 500);
|
||||
|
||||
/**
|
||||
* Two different clients subscribing via legacy (documents/collections)
|
||||
* and new (rows/tables) channels.
|
||||
*/
|
||||
$clientLegacy = $this->getWebsocket(['documents', 'collections'], [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
]);
|
||||
|
||||
$response = json_decode($clientLegacy->receive(), true);
|
||||
|
||||
$this->assertArrayHasKey('type', $response);
|
||||
$this->assertArrayHasKey('data', $response);
|
||||
$this->assertEquals('connected', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
|
||||
$clientTables = $this->getWebsocket(['rows', 'tables'], [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
]);
|
||||
|
||||
$response = json_decode($clientTables->receive(), true);
|
||||
|
||||
$this->assertArrayHasKey('type', $response);
|
||||
$this->assertArrayHasKey('data', $response);
|
||||
$this->assertEquals('connected', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertContains('rows', $response['data']['channels']);
|
||||
|
||||
/**
|
||||
* 1) Operation via legacy /databases API (document create).
|
||||
* Both clients should receive an event that includes both document-
|
||||
* style and row-style channels on the shared database.
|
||||
*/
|
||||
$documentId = ID::unique();
|
||||
|
||||
$document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
], $this->getHeaders()), [
|
||||
'documentId' => $documentId,
|
||||
'data' => [
|
||||
'name' => 'Legacy Chris Evans',
|
||||
],
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $document['headers']['status-code']);
|
||||
|
||||
$legacyEventForLegacyClient = json_decode($clientLegacy->receive(), true);
|
||||
$legacyEventForTablesClient = json_decode($clientTables->receive(), true);
|
||||
|
||||
foreach ([$legacyEventForLegacyClient, $legacyEventForTablesClient] as $event) {
|
||||
$this->assertArrayHasKey('type', $event);
|
||||
$this->assertArrayHasKey('data', $event);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertNotEmpty($event['data']);
|
||||
$this->assertArrayHasKey('timestamp', $event['data']);
|
||||
|
||||
$channels = $event['data']['channels'];
|
||||
|
||||
// Legacy-style channels
|
||||
$this->assertContains('documents', $channels);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$collectionId}.documents", $channels);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}", $channels);
|
||||
|
||||
// New rows-style channels mirrored for legacy API
|
||||
$this->assertContains('rows', $channels);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$collectionId}.rows", $channels);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$collectionId}.rows.{$documentId}", $channels);
|
||||
|
||||
// TablesDB-prefixed channels should also be present for a tablesdb database
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$collectionId}.rows", $channels);
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$collectionId}.rows.{$documentId}", $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) Operation via /tablesdb API (row create).
|
||||
* Both clients should again receive an event that now also includes
|
||||
* the tablesdb-prefixed channels alongside the databases-prefixed ones.
|
||||
*/
|
||||
$rowId = ID::unique();
|
||||
|
||||
$row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'rowId' => $rowId,
|
||||
'data' => [
|
||||
'name' => 'Chris Evans',
|
||||
],
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $row['headers']['status-code']);
|
||||
|
||||
$tablesEventForLegacyClient = json_decode($clientLegacy->receive(), true);
|
||||
$tablesEventForTablesClient = json_decode($clientTables->receive(), true);
|
||||
|
||||
foreach ([$tablesEventForLegacyClient, $tablesEventForTablesClient] as $event) {
|
||||
$this->assertArrayHasKey('type', $event);
|
||||
$this->assertArrayHasKey('data', $event);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertNotEmpty($event['data']);
|
||||
$this->assertArrayHasKey('timestamp', $event['data']);
|
||||
|
||||
$channels = $event['data']['channels'];
|
||||
|
||||
// Core tablesdb row channels
|
||||
$this->assertContains('rows', $channels);
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $channels);
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
|
||||
|
||||
// Collections/legacy-style compatibility channels
|
||||
$this->assertContains('documents', $channels);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $channels);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents", $channels);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents.{$rowId}", $channels);
|
||||
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.create", $event['data']['events']);
|
||||
$this->assertNotEmpty($event['data']['payload']);
|
||||
$this->assertEquals('Chris Evans', $event['data']['payload']['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Legacy database accessed via /tablesdb routes.
|
||||
* A database created via /databases but operated on via /tablesdb
|
||||
* should also expose both legacy and tablesdb-prefixed channels.
|
||||
*/
|
||||
$legacyDatabase = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
]), [
|
||||
'databaseId' => ID::unique(),
|
||||
'name' => 'Legacy DB via TablesDB Route',
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $legacyDatabase['headers']['status-code']);
|
||||
|
||||
$legacyDatabaseId = $legacyDatabase['body']['$id'];
|
||||
|
||||
$legacyTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'tableId' => ID::unique(),
|
||||
'name' => 'Legacy Actors',
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::create(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]);
|
||||
|
||||
$legacyTableId = $legacyTable['body']['$id'];
|
||||
|
||||
$legacyColumn = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/columns/string', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'key' => 'name',
|
||||
'size' => 256,
|
||||
'required' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $legacyColumn['headers']['status-code']);
|
||||
|
||||
$this->assertEventually(function () use ($legacyDatabaseId, $legacyTableId) {
|
||||
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/columns/name', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()));
|
||||
|
||||
$this->assertEquals(200, $column['headers']['status-code']);
|
||||
$this->assertEquals('available', $column['body']['status']);
|
||||
}, 120000, 500);
|
||||
|
||||
$legacyRowId = ID::unique();
|
||||
|
||||
$legacyRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $legacyDatabaseId . '/tables/' . $legacyTableId . '/rows', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'rowId' => $legacyRowId,
|
||||
'data' => [
|
||||
'name' => 'Legacy Tables Route',
|
||||
],
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $legacyRow['headers']['status-code']);
|
||||
|
||||
$legacyTablesEventForLegacyClient = json_decode($clientLegacy->receive(), true);
|
||||
$legacyTablesEventForTablesClient = json_decode($clientTables->receive(), true);
|
||||
|
||||
foreach ([$legacyTablesEventForLegacyClient, $legacyTablesEventForTablesClient] as $event) {
|
||||
$this->assertArrayHasKey('type', $event);
|
||||
$this->assertArrayHasKey('data', $event);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertNotEmpty($event['data']);
|
||||
$this->assertArrayHasKey('timestamp', $event['data']);
|
||||
|
||||
$channels = $event['data']['channels'];
|
||||
$events = $event['data']['events'];
|
||||
$this->assertIsList($channels);
|
||||
$this->assertIsList($events);
|
||||
|
||||
// Core tablesdb row channels for legacy db accessed via tablesdb
|
||||
$this->assertContains('rows', $channels);
|
||||
$this->assertContains("tablesdb.{$legacyDatabaseId}.tables.{$legacyTableId}.rows", $channels);
|
||||
$this->assertContains("tablesdb.{$legacyDatabaseId}.tables.{$legacyTableId}.rows.{$legacyRowId}", $channels);
|
||||
|
||||
// Legacy compatibility channels must also exist
|
||||
$this->assertContains('documents', $channels);
|
||||
$this->assertContains("databases.{$legacyDatabaseId}.tables.{$legacyTableId}.rows", $channels);
|
||||
$this->assertContains("databases.{$legacyDatabaseId}.tables.{$legacyTableId}.rows.{$legacyRowId}", $channels);
|
||||
$this->assertContains("databases.{$legacyDatabaseId}.collections.{$legacyTableId}.documents", $channels);
|
||||
$this->assertContains("databases.{$legacyDatabaseId}.collections.{$legacyTableId}.documents.{$legacyRowId}", $channels);
|
||||
}
|
||||
|
||||
$clientLegacy->close();
|
||||
$clientTables->close();
|
||||
}
|
||||
|
||||
public function testChannelTablesDBRowUpdate()
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$session = $user['session'] ?? '';
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
/**
|
||||
* Create a tablesdb database + table + column + row.
|
||||
*/
|
||||
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'databaseId' => ID::unique(),
|
||||
'name' => 'Row Update DB',
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $database['headers']['status-code']);
|
||||
$databaseId = $database['body']['$id'];
|
||||
|
||||
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'tableId' => ID::unique(),
|
||||
'name' => 'Assembly',
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::create(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $table['headers']['status-code']);
|
||||
$tableId = $table['body']['$id'];
|
||||
|
||||
$column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'key' => 'name',
|
||||
'size' => 256,
|
||||
'required' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $column['headers']['status-code']);
|
||||
|
||||
$this->assertEventually(function () use ($databaseId, $tableId) {
|
||||
$column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()));
|
||||
|
||||
$this->assertEquals(200, $column['headers']['status-code']);
|
||||
$this->assertEquals('available', $column['body']['status']);
|
||||
}, 120000, 500);
|
||||
|
||||
// Seed a row so we can listen to its update
|
||||
$rowId = ID::unique();
|
||||
|
||||
$row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'rowId' => $rowId,
|
||||
'data' => [
|
||||
'name' => 'Initial Name',
|
||||
],
|
||||
'permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
Permission::update(Role::any()),
|
||||
Permission::delete(Role::any()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $row['headers']['status-code']);
|
||||
|
||||
/**
|
||||
* Subscribe to a specific row channel using both legacy and tablesdb-style prefixes.
|
||||
* This mimics a client subscribing to a concrete "resource" channel and
|
||||
* expecting a single event list for updates.
|
||||
*/
|
||||
$client = $this->getWebsocket([
|
||||
"databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}",
|
||||
"tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}",
|
||||
], [
|
||||
'origin' => 'http://localhost',
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $session,
|
||||
]);
|
||||
|
||||
$response = json_decode($client->receive(), true);
|
||||
|
||||
$this->assertArrayHasKey('type', $response);
|
||||
$this->assertArrayHasKey('data', $response);
|
||||
$this->assertEquals('connected', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertIsList($response['data']['channels']);
|
||||
|
||||
/**
|
||||
* Trigger a row update via the dedicated /tablesdb row update endpoint.
|
||||
* Event label: databases.[databaseId].tables.[tableId].rows.[rowId].update
|
||||
* Our Event + Realtime logic should enrich this to include:
|
||||
* - databases.{dbId}.tables.{tableId}.rows.{rowId}.update
|
||||
* - tablesdb.{dbId}.tables.{tableId}.rows.{rowId}.update
|
||||
*/
|
||||
$update = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $this->getProject()['apiKey'],
|
||||
], $this->getHeaders()), [
|
||||
'data' => [
|
||||
'name' => 'Updated Name',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $update['headers']['status-code']);
|
||||
|
||||
$event = json_decode($client->receive(), true);
|
||||
|
||||
$this->assertArrayHasKey('type', $event);
|
||||
$this->assertArrayHasKey('data', $event);
|
||||
$this->assertEquals('event', $event['type']);
|
||||
$this->assertNotEmpty($event['data']);
|
||||
$this->assertArrayHasKey('timestamp', $event['data']);
|
||||
|
||||
$channels = $event['data']['channels'];
|
||||
$events = $event['data']['events'];
|
||||
|
||||
// Ensure channels and events are list-type arrays
|
||||
$this->assertIsList($channels);
|
||||
$this->assertIsList($events);
|
||||
|
||||
// Legacy + tablesdb row channels must be present
|
||||
$this->assertContains('rows', $channels);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $channels);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $channels);
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $channels);
|
||||
|
||||
// Both databases.* and tablesdb.* update events should be emitted
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update", $events);
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}.update", $events);
|
||||
|
||||
$this->assertNotEmpty($event['data']['payload']);
|
||||
$this->assertEquals('Updated Name', $event['data']['payload']['name']);
|
||||
|
||||
$client->close();
|
||||
}
|
||||
|
||||
public function testChannelDatabaseTransaction()
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
Reference in New Issue
Block a user