diff --git a/Dockerfile b/Dockerfile index 2cc724c887..2f85f2cc43 100755 --- a/Dockerfile +++ b/Dockerfile @@ -103,7 +103,6 @@ RUN chmod +x /usr/local/bin/hamster && \ chmod +x /usr/local/bin/patch-delete-schedule-updated-at-attribute && \ chmod +x /usr/local/bin/patch-recreate-repositories-documents && \ chmod +x /usr/local/bin/patch-delete-project-collections && \ - chmod +x /usr/local/bin/patch-delete-console-users && \ chmod +x /usr/local/bin/delete-orphaned-projects && \ chmod +x /usr/local/bin/clear-card-cache && \ chmod +x /usr/local/bin/calc-users-stats && \ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 75d6688f70..ea78f32ff4 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -165,6 +165,55 @@ App::post('/v1/account') ->dynamic($user, Response::MODEL_ACCOUNT); }); +App::delete('/v1/account') + ->desc('Delete account') + ->groups(['api', 'account']) + ->label('event', 'users.[userId].delete') + ->label('scope', 'account') + ->label('audits.event', 'user.delete') + ->label('audits.resource', 'user/{request.userId}') + ->label('usage.metric', 'users.{scope}.requests.delete') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'delete') + ->label('sdk.description', '/docs/references/account/delete.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->inject('requestTimestamp') + ->inject('request') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('queueForDeletes') + ->action(function (?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) { + + $clone = clone $user; + + $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->deleteDocument('users', $user->getId())); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($clone); + + $queueForEvents + ->setParam('userId', $clone->getId()) + ->setPayload($response->output($clone, Response::MODEL_ACCOUNT)); + + if (!Config::getParam('domainVerification')) { + $response->addHeader('X-Fallback-Cookies', \json_encode([])); + } + + $protocol = $request->getProtocol(); + $response + ->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ; + + $response->noContent(); + }); + + App::post('/v1/account/sessions/email') ->alias('/v1/account/sessions') ->desc('Create email session') @@ -2133,20 +2182,15 @@ App::patch('/v1/account/status') ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForDeletes') - ->action(function (?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) { + ->action(function (?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) { - $clone = clone $user; + $user->setAttribute('status', false); - $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->deleteDocument('users', $user->getId())); - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($clone); + $user = $dbForProject->withRequestTimestamp($requestTimestamp, fn () => $dbForProject->updateDocument('users', $user->getId(), $user)); $queueForEvents - ->setParam('userId', $clone->getId()) - ->setPayload($response->output($clone, Response::MODEL_ACCOUNT)); + ->setParam('userId', $user->getId()) + ->setPayload($response->output($user, Response::MODEL_ACCOUNT)); if (!Config::getParam('domainVerification')) { $response->addHeader('X-Fallback-Cookies', \json_encode([])); @@ -2158,7 +2202,7 @@ App::patch('/v1/account/status') ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ; - $response->dynamic($clone, Response::MODEL_ACCOUNT); + $response->dynamic($user, Response::MODEL_ACCOUNT); }); App::delete('/v1/account/sessions/:sessionId') diff --git a/bin/patch-delete-console-users b/bin/patch-delete-console-users deleted file mode 100644 index 3d5fbdf971..0000000000 --- a/bin/patch-delete-console-users +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -php /usr/src/code/app/cli.php patch-delete-console-users $@ \ No newline at end of file diff --git a/src/Appwrite/Platform/Services/Tasks.php b/src/Appwrite/Platform/Services/Tasks.php index c4de4bd706..42b4815292 100644 --- a/src/Appwrite/Platform/Services/Tasks.php +++ b/src/Appwrite/Platform/Services/Tasks.php @@ -20,7 +20,6 @@ use Appwrite\Platform\Tasks\CalcTierStats; use Appwrite\Platform\Tasks\Upgrade; use Appwrite\Platform\Tasks\DeleteOrphanedProjects; use Appwrite\Platform\Tasks\GetMigrationStats; -use Appwrite\Platform\Tasks\PatchDeleteConsoleUsers; class Tasks extends Service { @@ -44,9 +43,7 @@ class Tasks extends Service ->addAction(Specs::getName(), new Specs()) ->addAction(CalcTierStats::getName(), new CalcTierStats()) ->addAction(DeleteOrphanedProjects::getName(), new DeleteOrphanedProjects()) - ->addAction(PatchDeleteConsoleUsers::getName(), new PatchDeleteConsoleUsers()) ->addAction(GetMigrationStats::getName(), new GetMigrationStats()) - ->addAction(PatchDeleteConsoleUsers::getName(), new PatchDeleteConsoleUsers()) ; } } diff --git a/src/Appwrite/Platform/Tasks/PatchDeleteConsoleUsers.php b/src/Appwrite/Platform/Tasks/PatchDeleteConsoleUsers.php deleted file mode 100644 index 7d26adcf94..0000000000 --- a/src/Appwrite/Platform/Tasks/PatchDeleteConsoleUsers.php +++ /dev/null @@ -1,99 +0,0 @@ -desc('Delete console users patch') - ->inject('dbForConsole') - ->inject('queueForDeletes') - ->callback(fn ($dbForConsole, $queueForDeletes) => $this->action($dbForConsole, $queueForDeletes)); - } - - public function action(Database $dbForConsole, Delete $queueForDeletes): void - { - Console::info("Starting the patch"); - - $startTime = microtime(true); - $query = [Query::equal('status', [false])]; - $this->foreachDocument($dbForConsole, 'users', $query, function ($user) use ($dbForConsole, $queueForDeletes) { - $clone = clone $user; - try { - $dbForConsole->deleteDocument('users', $user->getId()); - $queueForDeletes - ->setProject(new Document([ - '$id' => ID::custom('console'), - '$internalId' => ID::custom('console') - ])) - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($clone) - ->trigger(); - } catch (\Throwable $th) { - Console::error("Unexpected error occurred with User ID {$clone->getId()}"); - Console::error('[Error] Type: ' . get_class($th)); - Console::error('[Error] Message: ' . $th->getMessage()); - Console::error('[Error] File: ' . $th->getFile()); - Console::error('[Error] Line: ' . $th->getLine()); - } - }); - - - $endTime = microtime(true); - $timeTaken = $endTime - $startTime; - - $hours = (int)($timeTaken / 3600); - $timeTaken -= $hours * 3600; - $minutes = (int)($timeTaken / 60); - $timeTaken -= $minutes * 60; - $seconds = (int)$timeTaken; - $milliseconds = ($timeTaken - $seconds) * 1000; - Console::info("Delete console users patch completed in $hours h, $minutes m, $seconds s, $milliseconds mis ( total $timeTaken milliseconds)"); - } - - protected function foreachDocument(Database $database, string $collection, array $queries = [], callable $callback = null): void - { - $limit = 1000; - $results = []; - $sum = $limit; - $latestDocument = null; - - while ($sum === $limit) { - $newQueries = $queries; - - if ($latestDocument != null) { - array_unshift($newQueries, Query::cursorAfter($latestDocument)); - } - $newQueries[] = Query::limit($limit); - $results = $database->find($collection, $newQueries); - - if (empty($results)) { - return; - } - - $sum = count($results); - - foreach ($results as $document) { - if (is_callable($callback)) { - $callback($document); - } - } - $latestDocument = $results[array_key_last($results)]; - } - } -} diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 56280b4b49..023cf824ff 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -90,6 +90,82 @@ class AccountCustomClientTest extends Scope return []; } + public function testDeleteAccount() + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name (blocked)'; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $response['body']['$id']; + + $this->assertEquals($response['headers']['status-code'], 201); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $response['body']['$id']; + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals($response['headers']['status-code'], 200); + + $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals($response['headers']['status-code'], 204); + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals($response['headers']['status-code'], 401); + + $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals($response['headers']['status-code'], 401); + } + + public function testBlockedAccount(): array { $email = uniqid() . 'user@localhost.test'; @@ -221,8 +297,6 @@ class AccountCustomClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ], [ - 'status' => false, ]); $this->assertEquals($response['headers']['status-code'], 200);