diff --git a/CHANGES.md b/CHANGES.md index d19c8490b7..fb46b2b7d1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +# Version 1.3.8 + +## Bugs +- Fix audit user internal [#5809](https://github.com/appwrite/appwrite/pull/5809) + # Version 1.3.7 ## Bugs diff --git a/README-CN.md b/README-CN.md index d3f52058b5..b634458547 100644 --- a/README-CN.md +++ b/README-CN.md @@ -66,7 +66,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.3.7 + appwrite/appwrite:1.3.8 ``` ### Windows @@ -78,7 +78,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.3.7 + appwrite/appwrite:1.3.8 ``` #### PowerShell @@ -88,7 +88,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.3.7 + appwrite/appwrite:1.3.8 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index b8fffd110a..2b82d49eb0 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.3.7 + appwrite/appwrite:1.3.8 ``` ### Windows @@ -87,7 +87,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.3.7 + appwrite/appwrite:1.3.8 ``` #### PowerShell @@ -97,7 +97,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.3.7 + appwrite/appwrite:1.3.8 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. @@ -128,6 +128,12 @@ Choose from one of the providers below:
Gitpod + + + Akamai Logo +
Akamai
+ + diff --git a/app/config/errors.php b/app/config/errors.php index 2983d22cd8..a3b6cab24d 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -227,6 +227,11 @@ return [ 'description' => 'The invite does not belong to the current user.', 'code' => 401, ], + Exception::TEAM_ALREADY_EXISTS => [ + 'name' => Exception::TEAM_ALREADY_EXISTS, + 'description' => 'Team with requested ID already exists.', + 'code' => 409, + ], /** Membership */ Exception::MEMBERSHIP_NOT_FOUND => [ @@ -318,6 +323,11 @@ return [ 'description' => 'The requested range is not satisfiable. Please check the value of the Range header.', 'code' => 416, ], + Exception::STORAGE_INVALID_APPWRITE_ID => [ + 'name' => Exception::STORAGE_INVALID_APPWRITE_ID, + 'description' => 'The value for x-appwrite-id header is invalid. Please check the value of the x-appwrite-id header is valid id and not unique().', + 'code' => 400, + ], /** Functions */ Exception::FUNCTION_NOT_FOUND => [ diff --git a/app/config/locale/translations/pt-pt.json b/app/config/locale/translations/pt-pt.json index e257b47281..cf9ef377a8 100644 --- a/app/config/locale/translations/pt-pt.json +++ b/app/config/locale/translations/pt-pt.json @@ -17,7 +17,7 @@ "emails.magicSession.signature": "Equipa {{project}}", "emails.recovery.subject": "Redefinição de senha", "emails.recovery.hello": "Olá {{name}}", - "emails.recovery.body": "tilize este link para redefinir a palavra-passe do seu projecto {{project}}", + "emails.recovery.body": "Utilize este link para redefinir a palavra-passe do seu projecto {{project}}", "emails.recovery.footer": "Se não pediu para redefinir a sua palavra-passe, pode ignorar esta mensagem.", "emails.recovery.thanks": "Obrigado", "emails.recovery.signature": "Equipa {{project}}", diff --git a/app/config/providers.php b/app/config/providers.php index 62430faae1..dedb4ec665 100644 --- a/app/config/providers.php +++ b/app/config/providers.php @@ -201,6 +201,16 @@ return [ // Ordered by ABC. 'beta' => false, 'mock' => false, ], + 'oidc' => [ + 'name' => 'OpenID Connect', + 'developers' => 'https://openid.net/connect/faq/', + 'icon' => 'icon-oidc', + 'enabled' => true, + 'sandbox' => false, + 'form' => 'oidc.phtml', + 'beta' => false, + 'mock' => false, + ], 'okta' => [ 'name' => 'Okta', 'developers' => 'https://developer.okta.com/', @@ -222,7 +232,7 @@ return [ // Ordered by ABC. 'mock' => false ], 'paypalSandbox' => [ - 'name' => 'PayPal', + 'name' => 'PayPal Sandbox', 'developers' => 'https://developer.paypal.com/docs/api/overview/', 'icon' => 'icon-paypal', 'enabled' => true, @@ -292,7 +302,7 @@ return [ // Ordered by ABC. 'mock' => false, ], 'tradeshiftBox' => [ - 'name' => 'Tradeshift', + 'name' => 'Tradeshift Sandbox', 'developers' => 'https://developers.tradeshift.com/docs/api', 'icon' => 'icon-tradeshiftbox', 'enabled' => true, diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 70c8f09456..e64b47f18b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -549,7 +549,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); $secret = Auth::tokenGenerator(); - $expire = DateTime::addSeconds(new \DateTime(), $duration); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $session = new Document(array_merge([ '$id' => ID::unique(), @@ -701,7 +701,7 @@ Http::post('/v1/account/sessions/magic-url') } $loginSecret = Auth::tokenGenerator(); - $expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); $token = new Document([ '$id' => ID::unique(), @@ -827,7 +827,7 @@ Http::put('/v1/account/sessions/magic-url') $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); $secret = Auth::tokenGenerator(); - $expire = DateTime::addSeconds(new \DateTime(), $duration); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $session = new Document(array_merge( [ @@ -976,7 +976,7 @@ Http::post('/v1/account/sessions/phone') } $secret = Auth::codeGenerator(); - $expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_PHONE); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_PHONE)); $token = new Document([ '$id' => ID::unique(), @@ -1064,7 +1064,7 @@ Http::put('/v1/account/sessions/phone') $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); $secret = Auth::tokenGenerator(); - $expire = DateTime::addSeconds(new \DateTime(), $duration); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $session = new Document(array_merge( [ @@ -1216,7 +1216,7 @@ Http::post('/v1/account/sessions/anonymous') $detector = new Detector($request->getUserAgent('UNKNOWN')); $record = $geodb->get($request->getIP()); $secret = Auth::tokenGenerator(); - $expire = DateTime::addSeconds(new \DateTime(), $duration); + $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $session = new Document(array_merge( [ @@ -1389,6 +1389,7 @@ Http::get('/v1/account/sessions') $session->setAttribute('countryName', $countryName); $session->setAttribute('current', ($current == $session->getId()) ? true : false); + $session->setAttribute('expire', DateTime::formatTz(DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration))); $sessions[$key] = $session; } @@ -1426,7 +1427,7 @@ Http::get('/v1/account/logs') $audit = new EventAudit($dbForProject); - $logs = $audit->getLogsByUser($user->getId(), $limit, $offset); + $logs = $audit->getLogsByUser($user->getInternalId(), $limit, $offset); $output = []; @@ -1495,7 +1496,7 @@ Http::get('/v1/account/sessions/:sessionId') $session ->setAttribute('current', ($session->getAttribute('secret') == Auth::hash(Auth::$secret))) ->setAttribute('countryName', $countryName) - ->setAttribute('expire', DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration)) + ->setAttribute('expire', DateTime::formatTz(DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration))) ; return $response->dynamic($session, Response::MODEL_SESSION); @@ -1939,7 +1940,7 @@ Http::patch('/v1/account/sessions/:sessionId') $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; - $session->setAttribute('expire', DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration)); + $session->setAttribute('expire', DateTime::formatTz(DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration))); $events ->setParam('userId', $user->getId()) @@ -2306,7 +2307,7 @@ Http::post('/v1/account/verification') ->setBody($body) ->setFrom($from) ->setRecipient($user->getAttribute('email')) - ->setName($user->getAttribute('name')) + ->setName($user->getAttribute('name') ?? '') ->trigger() ; @@ -2380,7 +2381,7 @@ Http::put('/v1/account/verification') $dbForProject->deleteCachedDocument('users', $profile->getId()); $events - ->setParam('userId', $user->getId()) + ->setParam('userId', $userId) ->setParam('tokenId', $verificationDocument->getId()) ; diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 706238fe8d..06c0373e48 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -566,7 +566,7 @@ Http::get('/v1/databases/:databaseId/logs') $output[$i] = new Document([ 'event' => $log['event'], - 'userId' => ID::custom($log['userId']), + 'userId' => ID::custom($log['data']['userId']), 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, @@ -917,7 +917,7 @@ Http::get('/v1/databases/:databaseId/collections/:collectionId/logs') $output[$i] = new Document([ 'event' => $log['event'], - 'userId' => $log['userId'], + 'userId' => $log['data']['userId'], 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, @@ -2535,18 +2535,11 @@ Http::get('/v1/databases/:databaseId/collections/:collectionId/indexes/:key') throw new Exception(Exception::COLLECTION_NOT_FOUND); } - $indexes = $collection->getAttribute('indexes'); - - // Search for index - $indexIndex = array_search($key, array_map(fn($idx) => $idx['key'], $indexes)); - - if ($indexIndex === false) { + $index = $collection->find('key', $key, 'indexes'); + if (empty($index)) { throw new Exception(Exception::INDEX_NOT_FOUND); } - $index = $indexes[$indexIndex]; - $index->setAttribute('collectionId', $database->getInternalId() . '_' . $collectionId); - $response->dynamic($index, Response::MODEL_INDEX); }); @@ -3149,7 +3142,7 @@ Http::get('/v1/databases/:databaseId/collections/:collectionId/documents/:docume $output[$i] = new Document([ 'event' => $log['event'], - 'userId' => $log['userId'], + 'userId' => $log['data']['userId'], 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index faa2d3ab07..4cb7555ac1 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -650,11 +650,13 @@ Http::post('/v1/functions/:functionId/deployments') $end = $request->getContentRangeEnd(); $fileSize = $request->getContentRangeSize(); $deploymentId = $request->getHeader('x-appwrite-id', $deploymentId); - if (is_null($start) || is_null($end) || is_null($fileSize)) { + // TODO make `end >= $fileSize` in next breaking version + if (is_null($start) || is_null($end) || is_null($fileSize) || $end > $fileSize) { throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } - if ($end === $fileSize) { + // TODO remove the condition that checks `$end === $fileSize` in next breaking version + if ($end === $fileSize - 1 || $end === $fileSize) { //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk $chunks = $chunk = -1; } else { diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index 6870d31ed5..299b1402ba 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -445,11 +445,18 @@ Http::post('/v1/storage/buckets/:bucketId/files') $end = $request->getContentRangeEnd(); $fileSize = $request->getContentRangeSize(); $fileId = $request->getHeader('x-appwrite-id', $fileId); - if (is_null($start) || is_null($end) || is_null($fileSize)) { + // TODO make `end >= $fileSize` in next breaking version + if (is_null($start) || is_null($end) || is_null($fileSize) || $end > $fileSize) { throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } - if ($end === $fileSize) { + $idValidator = new UID(); + if (!$idValidator->isValid($request->getHeader('x-appwrite-id'))) { + throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID); + } + + // TODO remove the condition that checks `$end === $fileSize` in next breaking version + if ($end === $fileSize - 1 || $end === $fileSize) { //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to -1 notify it's last chunk $chunks = $chunk = -1; } else { diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index a7f53196c9..728f4974fe 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -67,18 +67,23 @@ Http::post('/v1/teams') $isAppUser = Auth::isAppUser(Authorization::getRoles()); $teamId = $teamId == 'unique()' ? ID::unique() : $teamId; - $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')), - ], - 'name' => $name, - 'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1, - 'prefs' => new \stdClass(), - 'search' => implode(' ', [$teamId, $name]), - ]))); + + 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')), + ], + '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)) { @@ -338,11 +343,12 @@ Http::delete('/v1/teams/:teamId') Query::limit(2000), // TODO fix members limit ]); - // TODO delete all members individually from the user object + // Memberships are deleted here instead of in the worker to make sure user permisions are updated instantly foreach ($memberships as $membership) { if (!$dbForProject->deleteDocument('memberships', $membership->getId())) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove membership for team from DB'); } + $dbForProject->deleteCachedDocument('users', $membership->getAttribute('userId')); } if (!$dbForProject->deleteDocument('teams', $teamId)) { @@ -1038,7 +1044,7 @@ Http::get('/v1/teams/:teamId/logs') $output[$i] = new Document([ 'event' => $log['event'], - 'userId' => $log['userId'], + 'userId' => $log['data']['userId'], 'userEmail' => $log['data']['userEmail'] ?? null, 'userName' => $log['data']['userName'] ?? null, 'mode' => $log['data']['mode'] ?? null, diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index ea0200d741..de8a9a3996 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -577,7 +577,7 @@ Http::get('/v1/users/:userId/logs') $audit = new Audit($dbForProject); - $logs = $audit->getLogsByUser($user->getId(), $limit, $offset); + $logs = $audit->getLogsByUser($user->getInternalId(), $limit, $offset); $output = []; diff --git a/app/controllers/mock.php b/app/controllers/mock.php index dee02e1bce..b8f11619d4 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -258,7 +258,7 @@ Http::post('/v1/mock/tests/general/upload') $id = $request->getHeader('x-appwrite-id', ''); $file['size'] = (\is_array($file['size'])) ? $file['size'][0] : $file['size']; - if (is_null($start) || is_null($end) || is_null($size)) { + if (is_null($start) || is_null($end) || is_null($size) || $end >= $size) { throw new Exception(Exception::GENERAL_MOCK, 'Invalid content-range header'); } @@ -274,11 +274,11 @@ Http::post('/v1/mock/tests/general/upload') throw new Exception(Exception::GENERAL_MOCK, 'All chunked request must have id header (except first)'); } - if ($end !== $size && $end - $start + 1 !== $chunkSize) { + if ($end !== $size - 1 && $end - $start + 1 !== $chunkSize) { throw new Exception(Exception::GENERAL_MOCK, 'Chunk size must be 5MB (except last chunk)'); } - if ($end !== $size && $file['size'] !== $chunkSize) { + if ($end !== $size - 1 && $file['size'] !== $chunkSize) { throw new Exception(Exception::GENERAL_MOCK, 'Wrong chunk size'); } @@ -286,11 +286,11 @@ Http::post('/v1/mock/tests/general/upload') throw new Exception(Exception::GENERAL_MOCK, 'Chunk size must be 5MB or less'); } - if ($end !== $size) { + if ($end !== $size - 1) { $response->json([ '$id' => ID::custom('newfileid'), - 'chunksTotal' => $file['size'] / $chunkSize, - 'chunksUploaded' => $start / $chunkSize + 'chunksTotal' => (int) ceil($size / ($end + 1 - $start)), + 'chunksUploaded' => ceil($start / $chunkSize) + 1 ]); } } else { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 12b8a102cd..c1e0c967b6 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -27,7 +27,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar $parts = explode('.', $match); if (count($parts) !== 2) { - throw new Exception('Too less or too many parts', 400, Exception::GENERAL_ARGUMENT_INVALID); + throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose"); } $namespace = $parts[0] ?? ''; diff --git a/app/init.php b/app/init.php index 3c1ef9fbc6..253d5c5360 100644 --- a/app/init.php +++ b/app/init.php @@ -101,7 +101,7 @@ const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return const APP_KEY_ACCCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours const APP_CACHE_BUSTER = 506; -const APP_VERSION_STABLE = '1.3.7'; +const APP_VERSION_STABLE = '1.3.8'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/workers/audits.php b/app/workers/audits.php index b25430ec41..57614e60f6 100644 --- a/app/workers/audits.php +++ b/app/workers/audits.php @@ -40,8 +40,7 @@ class AuditsV1 extends Worker $dbForProject = $this->getProjectDB($project->getId()); $audit = new Audit($dbForProject); $audit->log( - userInternalId: $user->getInternalId(), - userId: $user->getId(), + userId: $user->getInternalId(), // Pass first, most verbose event pattern event: $event, resource: $resource, @@ -49,6 +48,7 @@ class AuditsV1 extends Worker ip: $ip, location: '', data: [ + 'userId' => $user->getId(), 'userName' => $userName, 'userEmail' => $userEmail, 'mode' => $mode, diff --git a/composer.json b/composer.json index 106d4ccb90..f88fc662c4 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "appwrite/php-runtimes": "0.11.*", "utopia-php/abuse": "0.25.*", "utopia-php/analytics": "0.2.*", - "utopia-php/audit": "0.26.*", + "utopia-php/audit": "0.27.*", "utopia-php/cache": "0.8.*", "utopia-php/cli": "dev-feat-framework-v2 as 0.13.0", "utopia-php/config": "0.2.*", diff --git a/composer.lock b/composer.lock index 502279c20c..ba887fa026 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "1dda97a2619f621b8645f3e8a06b78c7", + "content-hash": "a15f62920dedaf99e321ffd94e89d9f4", "packages": [ { "name": "adhocore/jwt", @@ -1906,16 +1906,16 @@ }, { "name": "utopia-php/audit", - "version": "0.26.0", + "version": "0.27.0", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "e7228080f14df28737fbb050c180c26d86ac0403" + "reference": "bdf89d7fe381bd4c891ad217612580a35e8c7642" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/e7228080f14df28737fbb050c180c26d86ac0403", - "reference": "e7228080f14df28737fbb050c180c26d86ac0403", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/bdf89d7fe381bd4c891ad217612580a35e8c7642", + "reference": "bdf89d7fe381bd4c891ad217612580a35e8c7642", "shasum": "" }, "require": { @@ -1947,9 +1947,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/0.26.0" + "source": "https://github.com/utopia-php/audit/tree/0.27.0" }, - "time": "2023-04-27T15:43:50+00:00" + "time": "2023-05-15T07:04:48+00:00" }, { "name": "utopia-php/cache", @@ -2158,23 +2158,25 @@ }, { "name": "utopia-php/domains", - "version": "v1.1.0", + "version": "0.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "1665e1d9932afa3be63b5c1e0dcfe01fe77d8e73" + "reference": "aaa8c9a96c69ccb397997b1f4f2299c66f77eefb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/1665e1d9932afa3be63b5c1e0dcfe01fe77d8e73", - "reference": "1665e1d9932afa3be63b5c1e0dcfe01fe77d8e73", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/aaa8c9a96c69ccb397997b1f4f2299c66f77eefb", + "reference": "aaa8c9a96c69ccb397997b1f4f2299c66f77eefb", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.0", + "utopia-php/framework": "0.*.*" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "laravel/pint": "1.2.*", + "phpunit/phpunit": "^9.3" }, "type": "library", "autoload": { @@ -2190,6 +2192,10 @@ { "name": "Eldad Fux", "email": "eldad@appwrite.io" + }, + { + "name": "Wess Cope", + "email": "wess@appwrite.io" } ], "description": "Utopia Domains library is simple and lite library for parsing web domains. This library is aiming to be as simple and easy to learn and use.", @@ -2206,9 +2212,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/master" + "source": "https://github.com/utopia-php/domains/tree/0.3.2" }, - "time": "2020-02-23T07:40:02+00:00" + "time": "2023-07-19T16:39:24+00:00" }, { "name": "utopia-php/framework", diff --git a/docs/references/account/create-magic-url-session.md b/docs/references/account/create-magic-url-session.md index 03383713ed..b1e63e144c 100644 --- a/docs/references/account/create-magic-url-session.md +++ b/docs/references/account/create-magic-url-session.md @@ -1,3 +1,3 @@ -Sends the user an email with a secret key for creating a session. If the provided user ID has not be registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [PUT /account/sessions/magic-url](/docs/client/account#accountUpdateMagicURLSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default. +Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [PUT /account/sessions/magic-url](/docs/client/account#accountUpdateMagicURLSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default. -A user is limited to 10 active sessions at a time by default. [Learn more about session limits](/docs/authentication-security#limits). \ No newline at end of file +A user is limited to 10 active sessions at a time by default. [Learn more about session limits](/docs/authentication-security#limits). diff --git a/docs/references/account/delete-session.md b/docs/references/account/delete-session.md index cd1f22f627..c7439638af 100644 --- a/docs/references/account/delete-session.md +++ b/docs/references/account/delete-session.md @@ -1 +1 @@ -Use this endpoint to log out the currently logged in user from all their account sessions across all of their different devices. When using the Session ID argument, only the unique session ID provided is deleted. +Logout the user. Use 'current' as the session ID to logout on this device, use a session ID to logout on another device. If you're looking to logout the user on all devices, use [Delete Sessions](/docs/client/account#accountDeleteSessions) instead. \ No newline at end of file diff --git a/docs/sdks/flutter/GETTING_STARTED.md b/docs/sdks/flutter/GETTING_STARTED.md index b9b8dc3a47..110ee3eb4a 100644 --- a/docs/sdks/flutter/GETTING_STARTED.md +++ b/docs/sdks/flutter/GETTING_STARTED.md @@ -46,6 +46,8 @@ For **Linux** add your app name and package name, Your package nam ### Mac OS For **Mac OS** add your app name and Bundle ID, You can find your Bundle Identifier in the General tab for your app's primary target in Xcode. +The Appwrite SDK uses ASWebAuthenticationSession on macOS 10.15+ to allow OAuth authentication. You have to change your macOS Deployment Target in Xcode to be macOS >= 10.15 to be able to build your app for macOS. + ### Web Appwrite 0.7, and the Appwrite Flutter SDK 0.3.0 have added support for Flutter Web. To build web apps that integrate with Appwrite successfully, all you have to do is add a web platform on your Appwrite project's dashboard and list the domain your website will use to allow communication to the Appwrite API. diff --git a/public/images/integrations/akamai-logo.svg b/public/images/integrations/akamai-logo.svg new file mode 100644 index 0000000000..cdcf31b4c2 --- /dev/null +++ b/public/images/integrations/akamai-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Appwrite/Auth/OAuth2/Oidc.php b/src/Appwrite/Auth/OAuth2/Oidc.php new file mode 100644 index 0000000000..de2eab65c8 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Oidc.php @@ -0,0 +1,296 @@ +getAuthorizationEndpoint() . '?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'state' => \json_encode($this->state), + 'scope' => \implode(' ', $this->getScopes()), + 'response_type' => 'code', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getTokenEndpoint(), + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'grant_type' => 'authorization_code' + ]) + ), true); + } + return $this->tokens; + } + + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getTokenEndpoint(), + $headers, + \http_build_query([ + 'refresh_token' => $refreshToken, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'grant_type' => 'refresh_token' + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['sub'])) { + return $user['sub']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['email'])) { + return $user['email']; + } + + return ''; + } + + /** + * Check if the User email is verified + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + return $user['email_verified'] ?? false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['name'])) { + return $user['name']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; + $user = $this->request('GET', $this->getUserinfoEndpoint(), $headers); + $this->user = \json_decode($user, true); + } + + return $this->user; + } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Extracts the well known endpoint from the JSON stored in appSecret. + * + * @return string + */ + protected function getWellKnownEndpoint(): string + { + $secret = $this->getAppSecret(); + return $secret['wellKnownEndpoint'] ?? ''; + } + + /** + * Extracts the authorization endpoint from the JSON stored in appSecret. + * + * If one is not provided, it will be retrieved from the well-known configuration. + * + * @return string + */ + protected function getAuthorizationEndpoint(): string + { + $secret = $this->getAppSecret(); + + $endpoint = $secret['authorizationEndpoint'] ?? ''; + if (!empty($endpoint)) { + return $endpoint; + } + + $wellKnownConfiguration = $this->getWellKnownConfiguration(); + return $wellKnownConfiguration['authorization_endpoint'] ?? ''; + } + + /** + * Extracts the token endpoint from the JSON stored in appSecret. + * + * If one is not provided, it will be retrieved from the well-known configuration. + * + * @return string + */ + protected function getTokenEndpoint(): string + { + $secret = $this->getAppSecret(); + + $endpoint = $secret['tokenEndpoint'] ?? ''; + if (!empty($endpoint)) { + return $endpoint; + } + + $wellKnownConfiguration = $this->getWellKnownConfiguration(); + return $wellKnownConfiguration['token_endpoint'] ?? ''; + } + + /** + * Extracts the userinfo endpoint from the JSON stored in appSecret. + * + * If one is not provided, it will be retrieved from the well-known configuration. + * + * @return string + */ + protected function getUserinfoEndpoint(): string + { + $secret = $this->getAppSecret(); + $endpoint = $secret['userinfoEndpoint'] ?? ''; + if (!empty($endpoint)) { + return $endpoint; + } + + $wellKnownConfiguration = $this->getWellKnownConfiguration(); + return $wellKnownConfiguration['userinfo_endpoint'] ?? ''; + } + + /** + * Get the well-known configuration using the well known endpoint + */ + protected function getWellKnownConfiguration(): array + { + if (empty($this->wellKnownConfiguration)) { + $response = $this->request('GET', $this->getWellKnownEndpoint()); + $this->wellKnownConfiguration = \json_decode($response, true); + } + + return $this->wellKnownConfiguration; + } + + /** + * Decode the JSON stored in appSecret + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + throw new \Exception('Invalid secret'); + } + return $secret; + } +} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 7ed022b452..ca896a60b5 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -83,6 +83,7 @@ class Exception extends \Exception public const TEAM_INVALID_SECRET = 'team_invalid_secret'; public const TEAM_MEMBERSHIP_MISMATCH = 'team_membership_mismatch'; public const TEAM_INVITE_MISMATCH = 'team_invite_mismatch'; + public const TEAM_ALREADY_EXISTS = 'team_already_exists'; /** Membership */ public const MEMBERSHIP_NOT_FOUND = 'membership_not_found'; @@ -106,6 +107,7 @@ class Exception extends \Exception public const STORAGE_BUCKET_NOT_FOUND = 'storage_bucket_not_found'; public const STORAGE_INVALID_CONTENT_RANGE = 'storage_invalid_content_range'; public const STORAGE_INVALID_RANGE = 'storage_invalid_range'; + public const STORAGE_INVALID_APPWRITE_ID = 'storage_invalid_appwrite_id'; /** Functions */ public const FUNCTION_NOT_FOUND = 'function_not_found'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index 4f027dc6ce..49182f3c0d 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -63,6 +63,7 @@ abstract class Migration '1.3.5' => 'V18', '1.3.6' => 'V18', '1.3.7' => 'V18', + '1.3.8' => 'V18', ]; /** diff --git a/src/Appwrite/Migration/Version/V18.php b/src/Appwrite/Migration/Version/V18.php index be628e0fbb..839269f940 100644 --- a/src/Appwrite/Migration/Version/V18.php +++ b/src/Appwrite/Migration/Version/V18.php @@ -133,6 +133,16 @@ class V18 extends Migration Console::warning("'options' from {$id}: {$th->getMessage()}"); } break; + case 'audit': + try { + /** + * Delete 'userInternalId' attribute + */ + $this->projectDB->deleteAttribute($id, 'userInternalId'); + } catch (\Throwable $th) { + Console::warning("'userInternalId' from {$id}: {$th->getMessage()}"); + } + break; default: break; } @@ -195,6 +205,34 @@ class V18 extends Migration Console::warning($th->getMessage()); } break; + case 'audit': + /** + * Set the userId to the userInternalId and add userId to data + */ + try { + $userId = $document->getAttribute('userId'); + $data = $document->getAttribute('data', []); + $mode = $data['mode'] ?? 'default'; + $user = match ($mode) { + 'admin' => $this->consoleDB->getDocument('users', $userId), + default => $this->projectDB->getDocument('users', $userId), + }; + + if ($user->isEmpty()) { + // The audit userId could already be an internal Id. + // Otherwise, the user could have been deleted. + // Nonetheless, there's nothing else we can do here. + break; + } + $internalId = $user->getInternalId(); + $document->setAttribute('userId', $internalId); + $data = $document->getAttribute('data', []); + $data['userId'] = $user->getId(); + $document->setAttribute('data', $data); + } catch (\Throwable $th) { + Console::warning($th->getMessage()); + } + break; } return $document; diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 6fbc794698..ed41c250fc 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -136,7 +136,7 @@ class Project extends Model 'type' => Response::MODEL_PROVIDER, 'description' => 'List of Providers.', 'default' => [], - 'example' => new \stdClass(), + 'example' => [new \stdClass()], 'array' => true, ]) ->addRule('platforms', [ @@ -273,7 +273,8 @@ class Project extends Model } $projectProviders[] = new Document([ - 'name' => ucfirst($key), + 'key' => $key, + 'name' => $provider['name'] ?? '', 'appId' => $providerValues[$key . 'Appid'] ?? '', 'secret' => $providerValues[$key . 'Secret'] ?? '', 'enabled' => $providerValues[$key . 'Enabled'] ?? false, diff --git a/src/Appwrite/Utopia/Response/Model/Provider.php b/src/Appwrite/Utopia/Response/Model/Provider.php index 0f14993508..c589011a46 100644 --- a/src/Appwrite/Utopia/Response/Model/Provider.php +++ b/src/Appwrite/Utopia/Response/Model/Provider.php @@ -15,6 +15,12 @@ class Provider extends Model public function __construct() { $this + ->addRule('key', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider.', + 'default' => '', + 'example' => 'github', + ]) ->addRule('name', [ 'type' => self::TYPE_STRING, 'description' => 'Provider name.', diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index ba751516d3..f6d10653d8 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -308,6 +308,7 @@ trait AccountBase $this->assertEquals(true, $response['body']['sessions'][0]['current']); + $this->assertNotFalse(\DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $response['body']['sessions'][0]['expire'])); /** * Test for FAILURE */ @@ -1295,7 +1296,7 @@ trait AccountBase $token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 256); - $expireTime = strpos($lastEmail['text'], 'expire=' . urlencode(DateTime::format(new \DateTime($response['body']['expire']))), 0); + $expireTime = strpos($lastEmail['text'], 'expire=' . urlencode($response['body']['expire']), 0); $this->assertNotFalse($expireTime); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index c0435762fe..6e9d2b6dbb 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -494,6 +494,18 @@ class AccountCustomClientTest extends Scope 'password' => $password, ]); + $this->assertEquals($response['headers']['status-code'], 201); + + $response = $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ]), [ + 'url' => 'http://localhost' + ]); + + $this->assertEquals($response['headers']['status-code'], 201); return []; diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 2b66277952..37ba2ede7b 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -405,7 +405,7 @@ class FunctionsCustomServerTest extends Scope $id = ''; while (!feof($handle)) { $curlFile = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode(@fread($handle, $chunkSize)), $mimeType, 'php-large-fx.tar.gz'); - $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size) . '/' . $size; + $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size - 1) . '/' . $size; if (!empty($id)) { $headers['x-appwrite-id'] = $id; } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index c09bd262e3..52991b4621 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -618,7 +618,7 @@ class ProjectsConsoleClientTest extends Scope foreach ($providers as $key => $provider) { $asserted = false; foreach ($response['body']['providers'] as $responseProvider) { - if ($responseProvider['name'] === ucfirst($key)) { + if ($responseProvider['key'] === $key) { $this->assertEquals('AppId-' . ucfirst($key), $responseProvider['appId']); $this->assertEquals('Secret-' . ucfirst($key), $responseProvider['secret']); $this->assertFalse($responseProvider['enabled']); @@ -660,7 +660,7 @@ class ProjectsConsoleClientTest extends Scope foreach ($providers as $key => $provider) { $asserted = false; foreach ($response['body']['providers'] as $responseProvider) { - if ($responseProvider['name'] === ucfirst($key)) { + if ($responseProvider['key'] === $key) { // On first provider, test enabled=false $this->assertEquals($i !== 0, $responseProvider['enabled']); $asserted = true; diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 3169077f99..e8890b89d0 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -100,7 +100,7 @@ trait StorageBase $id = ''; while (!feof($handle)) { $curlFile = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode(@fread($handle, $chunkSize)), $mimeType, 'large-file.mp4'); - $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size) . '/' . $size; + $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size - 1) . '/' . $size; if (!empty($id)) { $headers['x-appwrite-id'] = $id; } @@ -144,7 +144,7 @@ trait StorageBase ]; $id = ''; $curlFile = new \CURLFile('data://' . $mimeType . ';base64,' . base64_encode(@fread($handle, $chunkSize)), $mimeType, 'large-file.mp4'); - $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size) . '/' . $size; + $headers['content-range'] = 'bytes ' . ($counter * $chunkSize) . '-' . min(((($counter * $chunkSize) + $chunkSize) - 1), $size - 1) . '/' . $size; $res = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucket2['body']['$id'] . '/files', $this->getHeaders(), [ 'fileId' => $fileId, 'file' => $curlFile, @@ -240,6 +240,29 @@ trait StorageBase $this->assertEquals(400, $failedBucket['headers']['status-code']); + /** + * Test for FAILURE set x-appwrite-id to unique() + */ + $source = realpath(__DIR__ . '/../../../resources/logo.png'); + $totalSize = \filesize($source); + $res = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . $size . '/' . $size, + 'x-appwrite-id' => 'unique()', + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile($source, 'image/png', 'logo.png'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(400, $res['headers']['status-code']); + $this->assertEquals('The value for x-appwrite-id header is invalid. Please check the value of the x-appwrite-id header is valid id and not unique().', $res['body']['message']); + return ['bucketId' => $bucketId, 'fileId' => $file['body']['$id'], 'largeFileId' => $largeFile['body']['$id'], 'largeBucketId' => $bucket2['body']['$id']]; } diff --git a/tests/e2e/Services/Teams/TeamsBase.php b/tests/e2e/Services/Teams/TeamsBase.php index 04b5f93691..8e19bede17 100644 --- a/tests/e2e/Services/Teams/TeamsBase.php +++ b/tests/e2e/Services/Teams/TeamsBase.php @@ -79,6 +79,17 @@ trait TeamsBase $this->assertEquals(400, $response['headers']['status-code']); + $response = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $teamId, + 'name' => 'John' + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + $this->assertEquals('team_already_exists', $response['body']['type']); + return ['teamUid' => $teamUid, 'teamName' => $teamName]; } diff --git a/tests/e2e/Services/Teams/TeamsBaseServer.php b/tests/e2e/Services/Teams/TeamsBaseServer.php index aa1be49f41..5950824da3 100644 --- a/tests/e2e/Services/Teams/TeamsBaseServer.php +++ b/tests/e2e/Services/Teams/TeamsBaseServer.php @@ -4,6 +4,7 @@ namespace Tests\E2E\Services\Teams; use Tests\E2E\Client; use Utopia\Database\Validator\Datetime as DatetimeValidator; +use Utopia\Database\Helpers\ID; trait TeamsBaseServer { @@ -281,4 +282,81 @@ trait TeamsBaseServer $this->assertIsInt($response['body']['total']); $this->assertEquals(true, $dateValidator->isValid($response['body']['$createdAt'])); } + + public function testTeamDeleteUpdatesUserMembership() + { + // Array to store the IDs of newly created users + $new_users = []; + + /** + * Create a new team + */ + $new_team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'New Team Test', + 'roles' => ['admin', 'editor'], + ]); + + $this->assertEquals(201, $new_team['headers']['status-code']); + $this->assertNotEmpty($new_team['body']['$id']); + $this->assertEquals('New Team Test', $new_team['body']['name']); + $this->assertGreaterThan(-1, $new_team['body']['total']); + $this->assertIsInt($new_team['body']['total']); + $this->assertArrayHasKey('prefs', $new_team['body']); + + /** + * Use the Create Team Membership endpoint + * to create 5 new users and add them to the team immediately + */ + for ($i = 0; $i < 5; $i++) { + $new_membership = $this->client->call(Client::METHOD_POST, '/teams/' . $new_team['body']['$id'] . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'email' => 'newuser' . $i . '@localhost.test', + 'name' => 'New User ' . $i, + 'roles' => ['admin', 'editor'], + 'url' => 'http://localhost:5000/join-us#title' + ]); + + $this->assertEquals(201, $new_membership['headers']['status-code']); + $this->assertNotEmpty($new_membership['body']['$id']); + $this->assertNotEmpty($new_membership['body']['userId']); + $this->assertEquals('New User ' . $i, $new_membership['body']['userName']); + $this->assertEquals('newuser' . $i . '@localhost.test', $new_membership['body']['userEmail']); + $this->assertNotEmpty($new_membership['body']['teamId']); + $this->assertCount(2, $new_membership['body']['roles']); + $dateValidator = new DatetimeValidator(); + $this->assertEquals(true, $dateValidator->isValid($new_membership['body']['joined'])); + $this->assertEquals(true, $new_membership['body']['confirm']); + + $new_users[] = $new_membership['body']['userId']; + } + + /** + * Delete the team + */ + $team_del_response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $new_team['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $team_del_response['headers']['status-code']); + + /** + * Check that the team memberships for each of the new users has been deleted + */ + for ($i = 0; $i < 5; $i++) { + $membership = $this->client->call(Client::METHOD_GET, '/users/' . $new_users[$i] . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $membership['headers']['status-code']); + $this->assertEquals(0, $membership['body']['total']); + } + } }