mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46fc77053 | ||
|
|
a76a42d2dc | ||
|
|
76965252d8 | ||
|
|
0272a7f337 | ||
|
|
52d5ae50d9 | ||
|
|
cfddef3706 | ||
|
|
ca8d613614 | ||
|
|
8c579cf88d | ||
|
|
a57d970840 | ||
|
|
5d6e43e01b | ||
|
|
c1e2d4593d | ||
|
|
32ef15f89a | ||
|
|
b4a32c8265 | ||
|
|
244606ff1d | ||
|
|
9ee6304513 | ||
|
|
ff4e756958 | ||
|
|
19df20de61 | ||
|
|
7737139f23 | ||
|
|
a803bafbb8 |
@@ -289,6 +289,25 @@ return [
|
||||
'repoBranch' => 'main',
|
||||
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/agent-skills/CHANGELOG.md'),
|
||||
],
|
||||
[
|
||||
'key' => 'cursor-plugin',
|
||||
'name' => 'CursorPlugin',
|
||||
'version' => '0.1.0',
|
||||
'url' => 'https://github.com/appwrite/cursor-plugin.git',
|
||||
'enabled' => true,
|
||||
'beta' => false,
|
||||
'dev' => false,
|
||||
'hidden' => false,
|
||||
'family' => APP_SDK_PLATFORM_CONSOLE,
|
||||
'prism' => 'cursor-plugin',
|
||||
'source' => \realpath(__DIR__ . '/../sdks/console-cursor-plugin'),
|
||||
'gitUrl' => 'git@github.com:appwrite/cursor-plugin.git',
|
||||
'gitRepoName' => 'cursor-plugin',
|
||||
'gitUserName' => 'appwrite',
|
||||
'gitBranch' => 'dev',
|
||||
'repoBranch' => 'main',
|
||||
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/cursor-plugin/CHANGELOG.md'),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -77,8 +77,6 @@ Http::get('/v1/project/usage')
|
||||
METRIC_NETWORK_REQUESTS,
|
||||
METRIC_NETWORK_INBOUND,
|
||||
METRIC_NETWORK_OUTBOUND,
|
||||
METRIC_SITES_INBOUND,
|
||||
METRIC_SITES_OUTBOUND,
|
||||
METRIC_USERS,
|
||||
METRIC_EXECUTIONS,
|
||||
METRIC_DATABASES_STORAGE,
|
||||
@@ -340,16 +338,6 @@ Http::get('/v1/project/usage')
|
||||
$projectBandwidth[$item['date']] += $item['value'];
|
||||
}
|
||||
|
||||
foreach ($usage[METRIC_SITES_INBOUND] as $item) {
|
||||
$projectBandwidth[$item['date']] ??= 0;
|
||||
$projectBandwidth[$item['date']] += $item['value'];
|
||||
}
|
||||
|
||||
foreach ($usage[METRIC_SITES_OUTBOUND] as $item) {
|
||||
$projectBandwidth[$item['date']] ??= 0;
|
||||
$projectBandwidth[$item['date']] += $item['value'];
|
||||
}
|
||||
|
||||
|
||||
$network = [];
|
||||
foreach ($projectBandwidth as $date => $value) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -562,6 +562,11 @@ $http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, Swool
|
||||
|
||||
$log->addTag('method', $route?->getMethod() ?? $request->getMethod());
|
||||
$log->addTag('url', $route?->getPath() ?? $request->getURI());
|
||||
|
||||
if (str_contains($th->getMessage(), 'FTS_TERM or FTS_NUMB')) {
|
||||
$log->addTag('paramQueries', json_encode($request->getParam('queries')));
|
||||
}
|
||||
|
||||
$log->addTag('verboseType', get_class($th));
|
||||
$log->addTag('code', $th->getCode());
|
||||
// $log->addTag('projectId', $project->getId()); // TODO: Figure out how to get ProjectID, if it becomes relevant
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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
+8
-8
@@ -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",
|
||||
@@ -5215,16 +5215,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/vcs",
|
||||
"version": "1.0.1",
|
||||
"version": "2.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/vcs.git",
|
||||
"reference": "0f942f667319bb22cd420431e4e79107b41061f3"
|
||||
"reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14"
|
||||
},
|
||||
"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/058049326e04a2a0c2f0ce8ad00c7e84825aba14",
|
||||
"reference": "058049326e04a2a0c2f0ce8ad00c7e84825aba14",
|
||||
"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.0"
|
||||
},
|
||||
"time": "2026-02-06T10:11:58+00:00"
|
||||
"time": "2026-02-25T11:36:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/websocket",
|
||||
@@ -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,32 @@ class Event
|
||||
|
||||
$events = $pairedEvents;
|
||||
}
|
||||
// mirrored events can have duplicates in case of smaller events
|
||||
return 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());
|
||||
|
||||
|
||||
@@ -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,26 @@ class Realtime extends MessagingAdapter
|
||||
$tableId = $payload->getAttribute('$tableId', '');
|
||||
$collectionId = $payload->getAttribute('$collectionId', '');
|
||||
$resourceId = $tableId ?: $collectionId;
|
||||
$channels = [];
|
||||
// backward compat(tablesdb will have databases channels + tablesdb prefixed channels)
|
||||
if ($parts[0] === 'databases' || $parts[0] === 'tablesdb') {
|
||||
$prefix = 'databases';
|
||||
|
||||
$channels[] = 'rows';
|
||||
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows';
|
||||
$channels[] = 'databases.' . $database->getId() . '.tables.' . $resourceId . '.rows.' . $payload->getId();
|
||||
$channels = self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), $prefix);
|
||||
|
||||
$channels[] = 'documents';
|
||||
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents';
|
||||
$channels[] = 'databases.' . $database->getId() . '.collections.' . $resourceId . '.documents.' . $payload->getId();
|
||||
$channels = array_unique([
|
||||
...$channels,
|
||||
...self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), $prefix)
|
||||
]);
|
||||
}
|
||||
|
||||
// prefixed channels -> tablesdb
|
||||
if ($parts[0] !== 'databases') {
|
||||
$channels = array_unique([
|
||||
...$channels,
|
||||
...self::getDatabaseChannels($parts[0], $database->getId(), $resourceId, $payload->getId()),
|
||||
]);
|
||||
}
|
||||
|
||||
$roles = $collection->getAttribute('documentSecurity', false)
|
||||
? \array_merge($collection->getRead(), $payload->getRead())
|
||||
@@ -572,4 +585,55 @@ 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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ 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'] ?? '');
|
||||
|
||||
@@ -6,6 +6,7 @@ use Appwrite\SDK\Language\AgentSkills;
|
||||
use Appwrite\SDK\Language\Android;
|
||||
use Appwrite\SDK\Language\Apple;
|
||||
use Appwrite\SDK\Language\CLI;
|
||||
use Appwrite\SDK\Language\CursorPlugin;
|
||||
use Appwrite\SDK\Language\Dart;
|
||||
use Appwrite\SDK\Language\Deno;
|
||||
use Appwrite\SDK\Language\DotNet;
|
||||
@@ -60,6 +61,7 @@ class SDKs extends Action
|
||||
'rest',
|
||||
'markdown',
|
||||
'agent-skills',
|
||||
'cursor-plugin'
|
||||
];
|
||||
|
||||
public static function getName(): string
|
||||
@@ -309,6 +311,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
case 'agent-skills':
|
||||
$config = new AgentSkills();
|
||||
break;
|
||||
case 'cursor-plugin':
|
||||
$config = new CursorPlugin();
|
||||
break;
|
||||
default:
|
||||
throw new \Exception('Language "' . $language['key'] . '" not supported');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2559,6 +2559,126 @@ class RealtimeCustomClientTest extends Scope
|
||||
$client->close();
|
||||
}
|
||||
|
||||
public function testChannelsTablesDB()
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$session = $user['session'] ?? '';
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
$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' => 'TablesDB Realtime DB',
|
||||
]);
|
||||
|
||||
$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' => '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);
|
||||
|
||||
$client = $this->getWebsocket(['documents', 'collections'], [
|
||||
'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->assertCount(2, $response['data']['channels']);
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
|
||||
$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']);
|
||||
|
||||
$response = json_decode($client->receive(), true);
|
||||
|
||||
$this->assertArrayHasKey('type', $response);
|
||||
$this->assertArrayHasKey('data', $response);
|
||||
$this->assertEquals('event', $response['type']);
|
||||
$this->assertNotEmpty($response['data']);
|
||||
$this->assertArrayHasKey('timestamp', $response['data']);
|
||||
|
||||
// Core channels for tablesdb row events
|
||||
$this->assertContains('rows', $response['data']['channels']);
|
||||
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows", $response['data']['channels']);
|
||||
$this->assertContains("tablesdb.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $response['data']['channels']);
|
||||
|
||||
// Collections-style compatibility channels
|
||||
$this->assertContains('documents', $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents", $response['data']['channels']);
|
||||
$this->assertContains("databases.{$databaseId}.collections.{$tableId}.documents.{$rowId}", $response['data']['channels']);
|
||||
|
||||
// Primary event should still be present
|
||||
$this->assertContains("databases.{$databaseId}.tables.{$tableId}.rows.{$rowId}.create", $response['data']['events']);
|
||||
$this->assertNotEmpty($response['data']['payload']);
|
||||
$this->assertEquals('Chris Evans', $response['data']['payload']['name']);
|
||||
|
||||
$client->close();
|
||||
}
|
||||
|
||||
public function testChannelDatabaseTransaction()
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
Reference in New Issue
Block a user