diff --git a/app/config/collections.php b/app/config/collections.php index 3fb44cdea9..bd379b0cce 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -2117,6 +2117,28 @@ $commonCollections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('sessionId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('sessionInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('providerType'), 'type' => Database::VAR_STRING, diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0ec078efbc..da8b1c9d2e 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1967,76 +1967,6 @@ App::post('/v1/account/jwt') ])]), Response::MODEL_JWT); }); -App::post('/v1/account/targets/push') - ->desc('Create Account\'s push target') - ->groups(['api', 'account']) - ->label('scope', 'account') - ->label('audits.event', 'target.create') - ->label('audits.resource', 'target/response.$id') - ->label('event', 'users.[userId].targets.[targetId].create') - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION]) - ->label('sdk.namespace', 'account') - ->label('sdk.method', 'createPushTarget') - ->label('sdk.response.code', Response::STATUS_CODE_CREATED) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_TARGET) - ->param('targetId', '', new CustomId(), 'Target 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('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)') - ->param('providerId', '', new UID(), 'Provider ID. Message will be sent to this target from the specified provider ID. If no provider ID is set the first setup provider will be used.', true) - ->inject('queueForEvents') - ->inject('user') - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $targetId, string $providerId, string $identifier, Event $queueForEvents, Document $user, Request $request, Response $response, Database $dbForProject) { - $targetId = $targetId == 'unique()' ? ID::unique() : $targetId; - - $provider = Authorization::skip(fn () => $dbForProject->getDocument('providers', $providerId)); - - if ($user->isEmpty()) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - $target = Authorization::skip(fn () => $dbForProject->getDocument('targets', $targetId)); - - if (!$target->isEmpty()) { - throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); - } - - $detector = new Detector($request->getUserAgent()); - $detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then) - - $device = $detector->getDevice(); - - try { - $target = $dbForProject->createDocument('targets', new Document([ - '$id' => $targetId, - '$permissions' => [ - Permission::read(Role::user($user->getId())), - Permission::update(Role::user($user->getId())), - ], - 'providerId' => !empty($providerId) ? $providerId : null, - 'providerInternalId' => !empty($providerId) ? $provider->getInternalId() : null, - 'providerType' => MESSAGE_TYPE_PUSH, - 'userId' => $user->getId(), - 'userInternalId' => $user->getInternalId(), - 'identifier' => $identifier, - 'name' => "{$device['deviceBrand']} {$device['deviceModel']}" - ])); - } catch (Duplicate) { - throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); - } - $dbForProject->purgeCachedDocument('users', $user->getId()); - - $queueForEvents - ->setParam('userId', $user->getId()) - ->setParam('targetId', $target->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($target, Response::MODEL_TARGET); - }); - App::get('/v1/account') ->desc('Get account') ->groups(['api', 'account']) @@ -2591,8 +2521,9 @@ App::delete('/v1/account/sessions/:sessionId') ->inject('dbForProject') ->inject('locale') ->inject('queueForEvents') + ->inject('queueForDeletes') ->inject('project') - ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Document $project) { + ->action(function (?string $sessionId, ?\DateTime $requestTimestamp, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes, Document $project) { $protocol = $request->getProtocol(); $sessionId = ($sessionId === 'current') @@ -2601,43 +2532,48 @@ App::delete('/v1/account/sessions/:sessionId') $sessions = $user->getAttribute('sessions', []); - foreach ($sessions as $key => $session) {/** @var Document $session */ - if ($sessionId == $session->getId()) { - $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $session) { - return $dbForProject->deleteDocument('sessions', $session->getId()); - }); + foreach ($sessions as $key => $session) { + /** @var Document $session */ + if ($sessionId !== $session->getId()) { + continue; + } - unset($sessions[$key]); + $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $session) { + return $dbForProject->deleteDocument('sessions', $session->getId()); + }); - $session->setAttribute('current', false); + unset($sessions[$key]); - if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too - $session - ->setAttribute('current', true) - ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))) - ; + $session->setAttribute('current', false); - if (!Config::getParam('domainVerification')) { - $response - ->addHeader('X-Fallback-Cookies', \json_encode([])) - ; - } + if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too + $session + ->setAttribute('current', true) + ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))); - $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')) - ; + if (!Config::getParam('domainVerification')) { + $response->addHeader('X-Fallback-Cookies', \json_encode([])); } - $dbForProject->purgeCachedDocument('users', $user->getId()); - - $queueForEvents - ->setParam('userId', $user->getId()) - ->setParam('sessionId', $session->getId()) - ->setPayload($response->output($session, Response::MODEL_SESSION)) - ; - return $response->noContent(); + $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')); } + + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('sessionId', $session->getId()) + ->setPayload($response->output($session, Response::MODEL_SESSION)); + + $queueForDeletes + ->setType(DELETE_TYPE_SESSION_TARGETS) + ->setDocument($session) + ->trigger(); + + $response->noContent(); + return; } throw new Exception(Exception::USER_SESSION_NOT_FOUND); @@ -2739,7 +2675,8 @@ App::delete('/v1/account/sessions') ->inject('dbForProject') ->inject('locale') ->inject('queueForEvents') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents) { + ->inject('queueForDeletes') + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Delete $queueForDeletes) { $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); @@ -2753,8 +2690,7 @@ App::delete('/v1/account/sessions') $session ->setAttribute('current', false) - ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))) - ; + ->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'))); if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { $session->setAttribute('current', true); @@ -2765,7 +2701,13 @@ App::delete('/v1/account/sessions') ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')); // Use current session for events. - $queueForEvents->setPayload($response->output($session, Response::MODEL_SESSION)); + $queueForEvents + ->setPayload($response->output($session, Response::MODEL_SESSION)); + + $queueForDeletes + ->setType(DELETE_TYPE_SESSION_TARGETS) + ->setDocument($session) + ->trigger(); } } @@ -3814,63 +3756,6 @@ App::put('/v1/account/mfa/challenge') $response->dynamic($session, Response::MODEL_SESSION); }); -App::put('/v1/account/targets/:targetId/push') - ->desc('Update Account\'s push target') - ->groups(['api', 'account']) - ->label('scope', 'account') - ->label('audits.event', 'target.update') - ->label('audits.resource', 'target/response.$id') - ->label('event', 'users.[userId].targets.[targetId].update') - ->label('sdk.auth', [APP_AUTH_TYPE_SESSION]) - ->label('sdk.namespace', 'account') - ->label('sdk.method', 'updatePushTarget') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_TARGET) - ->param('targetId', '', new UID(), 'Target ID.') - ->param('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)') - ->inject('queueForEvents') - ->inject('user') - ->inject('request') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $targetId, string $identifier, Event $queueForEvents, Document $user, Request $request, Response $response, Database $dbForProject) { - if ($user->isEmpty()) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - $target = Authorization::skip(fn () => $dbForProject->getDocument('targets', $targetId)); - - if ($target->isEmpty()) { - throw new Exception(Exception::USER_TARGET_NOT_FOUND); - } - - if ($user->getId() !== $target->getAttribute('userId')) { - throw new Exception(Exception::USER_TARGET_NOT_FOUND); - } - - if ($identifier) { - $target->setAttribute('identifier', $identifier); - } - - $detector = new Detector($request->getUserAgent()); - $detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then) - - $device = $detector->getDevice(); - - $target->setAttribute('name', "{$device['deviceBrand']} {$device['deviceModel']}"); - - $target = $dbForProject->updateDocument('targets', $target->getId(), $target); - $dbForProject->purgeCachedDocument('users', $user->getId()); - - $queueForEvents - ->setParam('userId', $user->getId()) - ->setParam('targetId', $target->getId()); - - $response - ->dynamic($target, Response::MODEL_TARGET); - }); - App::delete('/v1/account') ->desc('Delete account') ->groups(['api', 'account']) @@ -3906,3 +3791,179 @@ App::delete('/v1/account') $response->noContent(); }); + +App::post('/v1/account/targets/push') + ->desc('Create a push target') + ->groups(['api', 'account']) + ->label('scope', 'targets.write') + ->label('audits.event', 'target.create') + ->label('audits.resource', 'target/response.$id') + ->label('event', 'users.[userId].targets.[targetId].create') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createPushTarget') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TARGET) + ->param('targetId', '', new CustomId(), 'Target 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('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)') + ->param('providerId', '', new UID(), 'Provider ID. Message will be sent to this target from the specified provider ID. If no provider ID is set the first setup provider will be used.', true) + ->inject('queueForEvents') + ->inject('user') + ->inject('session') + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $targetId, string $identifier, string $providerId, Event $queueForEvents, Document $user, Request $request, Response $response, Database $dbForProject) { + $targetId = $targetId == 'unique()' ? ID::unique() : $targetId; + + $provider = Authorization::skip(fn () => $dbForProject->getDocument('providers', $providerId)); + + $target = Authorization::skip(fn () => $dbForProject->getDocument('targets', $targetId)); + + if (!$target->isEmpty()) { + throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); + } + + $detector = new Detector($request->getUserAgent()); + $detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then) + + $device = $detector->getDevice(); + + $sessionId = Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret); + $session = $dbForProject->getDocument('sessions', $sessionId); + + try { + $target = $dbForProject->createDocument('targets', new Document([ + '$id' => $targetId, + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], + 'providerId' => !empty($providerId) ? $providerId : null, + 'providerInternalId' => !empty($providerId) ? $provider->getInternalId() : null, + 'providerType' => MESSAGE_TYPE_PUSH, + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'sessionId' => $session->getId(), + 'sessionInternalId' => $session->getInternalId(), + 'identifier' => $identifier, + 'name' => "{$device['deviceBrand']} {$device['deviceModel']}" + ])); + } catch (Duplicate) { + throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); + } + + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('targetId', $target->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($target, Response::MODEL_TARGET); + }); + +App::put('/v1/account/targets/:targetId/push') + ->desc('Update a push target') + ->groups(['api', 'account']) + ->label('scope', 'targets.write') + ->label('audits.event', 'target.update') + ->label('audits.resource', 'target/response.$id') + ->label('event', 'users.[userId].targets.[targetId].update') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updatePushTarget') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TARGET) + ->param('targetId', '', new UID(), 'Target ID.') + ->param('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)') + ->inject('queueForEvents') + ->inject('user') + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $targetId, string $identifier, Event $queueForEvents, Document $user, Request $request, Response $response, Database $dbForProject) { + + $target = Authorization::skip(fn () => $dbForProject->getDocument('targets', $targetId)); + + if ($target->isEmpty()) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + if ($user->getId() !== $target->getAttribute('userId')) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + if ($identifier) { + $target->setAttribute('identifier', $identifier); + } + + $detector = new Detector($request->getUserAgent()); + $detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then) + + $device = $detector->getDevice(); + + $target->setAttribute('name', "{$device['deviceBrand']} {$device['deviceModel']}"); + + $target = $dbForProject->updateDocument('targets', $target->getId(), $target); + + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('targetId', $target->getId()); + + $response + ->dynamic($target, Response::MODEL_TARGET); + }); + +App::delete('/v1/account/targets/:targetId/push') + ->desc('Delete a push target') + ->groups(['api', 'account']) + ->label('scope', 'targets.write') + ->label('audits.event', 'target.delete') + ->label('audits.resource', 'target/response.$id') + ->label('event', 'users.[userId].targets.[targetId].delete') + ->label('sdk.auth', [APP_AUTH_TYPE_SESSION]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'deletePushTarget') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TARGET) + ->param('targetId', '', new UID(), 'Target ID.') + ->inject('queueForEvents') + ->inject('queueForDeletes') + ->inject('user') + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $targetId, Event $queueForEvents, Delete $queueForDeletes, Document $user, Request $request, Response $response, Database $dbForProject) { + $target = Authorization::skip(fn() => $dbForProject->getDocument('targets', $targetId)); + + if ($target->isEmpty()) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + if ($user->getInternalId() !== $target->getAttribute('userInternalId')) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + $dbForProject->deleteDocument('targets', $target->getId()); + + $dbForProject->purgeCachedDocument('users', $user->getId()); + + $queueForDeletes + ->setType(DELETE_TYPE_TARGET) + ->setDocument($target); + + $queueForEvents + ->setParam('userId', $user->getId()) + ->setParam('targetId', $target->getId()) + ->setPayload($response->output($target, Response::MODEL_TARGET)); + + $response->noContent(); + }); diff --git a/app/init.php b/app/init.php index 16a33955fd..26f9c7c34a 100644 --- a/app/init.php +++ b/app/init.php @@ -177,6 +177,7 @@ const DELETE_TYPE_SCHEDULES = 'schedules'; const DELETE_TYPE_TOPIC = 'topic'; const DELETE_TYPE_TARGET = 'target'; const DELETE_TYPE_EXPIRED_TARGETS = 'invalid_targets'; +const DELETE_TYPE_SESSION_TARGETS = 'session_targets'; // Mail Types const MAIL_TYPE_VERIFICATION = 'verification'; const MAIL_TYPE_MAGIC_SESSION = 'magicSession'; diff --git a/composer.lock b/composer.lock index a6e646e93c..dc1e9085ea 100644 --- a/composer.lock +++ b/composer.lock @@ -1543,16 +1543,16 @@ }, { "name": "utopia-php/database", - "version": "0.48.1", + "version": "0.48.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "52abe057180a76fe354a516300344b33f268f6ea" + "reference": "0a231a2874fdbc0cf2ae2170b3f132fdee0ddfd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/52abe057180a76fe354a516300344b33f268f6ea", - "reference": "52abe057180a76fe354a516300344b33f268f6ea", + "url": "https://api.github.com/repos/utopia-php/database/zipball/0a231a2874fdbc0cf2ae2170b3f132fdee0ddfd4", + "reference": "0a231a2874fdbc0cf2ae2170b3f132fdee0ddfd4", "shasum": "" }, "require": { @@ -1593,9 +1593,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.48.1" + "source": "https://github.com/utopia-php/database/tree/0.48.2" }, - "time": "2024-02-02T04:54:13+00:00" + "time": "2024-02-02T14:10:14+00:00" }, { "name": "utopia-php/domains", @@ -5523,5 +5523,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index dd1949f845..f22fa9ca4e 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -164,6 +164,9 @@ class Deletes extends Action case DELETE_TYPE_EXPIRED_TARGETS: $this->deleteExpiredTargets($project, $getProjectDB); break; + case DELETE_TYPE_SESSION_TARGETS: + $this->deleteSessionTargets($project, $getProjectDB, $document); + break; default: throw new \Exception('No delete operation for type: ' . \strval($type)); } @@ -249,7 +252,7 @@ class Deletes extends Action * @param Document $target * @throws Exception */ - private function deleteTargetSubscribers(Document $project, callable $getProjectDB, Document $target) + private function deleteTargetSubscribers(Document $project, callable $getProjectDB, Document $target): void { /** @var Database */ $dbForProject = $getProjectDB($project); @@ -279,7 +282,7 @@ class Deletes extends Action * @return void * @throws Exception */ - private function deleteExpiredTargets(Document $project, callable $getProjectDB) + private function deleteExpiredTargets(Document $project, callable $getProjectDB): void { $this->deleteByGroup( 'targets', @@ -293,6 +296,20 @@ class Deletes extends Action ); } + private function deleteSessionTargets(Document $project, callable $getProjectDB, Document $session): void + { + $this->deleteByGroup( + 'targets', + [ + Query::equal('sessionInternalId', [$session->getInternalId()]) + ], + $getProjectDB($project), + function (Document $target) use ($getProjectDB, $project) { + $this->deleteTargetSubscribers($project, $getProjectDB, $target); + } + ); + } + /** * @param Document $project * @param callable $getProjectDB