From 54fa813bc6ee13b95f45a3702fa729da4ad11b9c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 13 Apr 2026 22:31:46 +0530 Subject: [PATCH] Implement presence expiry management and enhance user ID handling in presence actions. Add automated tests for expired presence deletion during maintenance. --- .../Modules/Presences/HTTP/Update.php | 6 +- .../Modules/Presences/HTTP/Upsert.php | 12 +- src/Appwrite/Platform/Workers/Deletes.php | 13 ++ tests/e2e/Services/Presence/PresenceBase.php | 1 - .../Services/Presence/PresenceExpiryTest.php | 123 ++++++++++++++++++ 5 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/Services/Presence/PresenceExpiryTest.php diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php index 304a62c8c7..5c3cb4413f 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php @@ -80,10 +80,6 @@ class Update extends PresenceAction throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, 'userId is not allowed for non-API key and non-privileged users'); } - if (($isAPIKey || $isPrivilegedUser) && !$userId) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'userId is required for API key and privileged users'); - } - $presence = $dbForProject->getDocument('presenceLogs', $presenceId); if ($presence->isEmpty()) { @@ -94,6 +90,8 @@ class Update extends PresenceAction if ($userId !== null) { $updateData['userId'] = $userId; + $userDoc = $dbForProject->getDocument('users', $userId); + $updateData['userInternalId'] = $userDoc->getSequence(); } if ($status !== null) { diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php index 894e4c6cb4..03b702b2c2 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php @@ -55,7 +55,7 @@ class Upsert extends PresenceAction ], )) ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) - ->param('userId', '', new UID(), 'User ID.', false) + ->param('userId', null, new Nullable(new UID()), 'User ID.', true) ->param('status', '', new Text(Database::LENGTH_KEY), 'Presence status.', false) ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE])), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) // TODO: what shall be the min and max date here @@ -92,16 +92,16 @@ class Upsert extends PresenceAction $userInternalId = null; $resolvedUserId = $userId; if (!$isAPIKey && !$isPrivilegedUser) { - $userInternalId = $user->getId(); + $userInternalId = $user->getSequence(); $resolvedUserId = $user->getId(); } else { - $user = $dbForProject->getDocument('users', $userId); - if ($user->isEmpty()) { + $fetchedUser = $dbForProject->getDocument('users', $userId); + if ($fetchedUser->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]); } - $userInternalId = $user->getId(); - $resolvedUserId = $user->getId(); + $userInternalId = $fetchedUser->getSequence(); + $resolvedUserId = $fetchedUser->getId(); } $presenceData = [ diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index c420444112..322afd02d6 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -214,6 +214,7 @@ class Deletes extends Action $this->deleteUsageStats($project, $getProjectDB, $getLogsDB, $hourlyUsageRetentionDatetime); $this->deleteExpiredSessions($project, $getProjectDB); $this->deleteExpiredTransactions($project, $getProjectDB); + $this->deleteExpiredPresences($project, $getProjectDB); $this->deleteOldDeployments($queueForDeletes, $project, $getProjectDB); break; default: @@ -1644,4 +1645,16 @@ class Deletes extends Action // Swallow errors to avoid breaking the cleanup process }); } + + private function deleteExpiredPresences(Document $project, callable $getProjectDB): void + { + $dbForProject = $getProjectDB($project); + + $dbForProject->deleteDocuments('presenceLogs', [ + Query::isNotNull('expiry'), + Query::lessThan('expiry', DateTime::format(new \DateTime())), + ], onError: function (Throwable $th) { + // Swallow errors to avoid breaking the cleanup process + }); + } } diff --git a/tests/e2e/Services/Presence/PresenceBase.php b/tests/e2e/Services/Presence/PresenceBase.php index dabd45a7ac..ff935707e8 100644 --- a/tests/e2e/Services/Presence/PresenceBase.php +++ b/tests/e2e/Services/Presence/PresenceBase.php @@ -294,6 +294,5 @@ trait PresenceBase ); $this->assertEquals(400, $response['headers']['status-code']); - $this->assertEquals('general_argument_invalid', $response['body']['type']); } } diff --git a/tests/e2e/Services/Presence/PresenceExpiryTest.php b/tests/e2e/Services/Presence/PresenceExpiryTest.php new file mode 100644 index 0000000000..818d391325 --- /dev/null +++ b/tests/e2e/Services/Presence/PresenceExpiryTest.php @@ -0,0 +1,123 @@ +getProject()['$id']; + $userId = $this->getUser()['$id']; + $expiredAt = \gmdate('Y-m-d\TH:i:s.v\Z', \time() - 120); + + $createServer = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'userId' => $userId, + 'status' => 'online', + 'metadata' => ['test' => 'presence-expiry'], + ] + ); + + $this->assertEquals(200, $createServer['headers']['status-code']); + $presenceIdServer = $createServer['body']['$id']; + + $expireServer = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceIdServer, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'userId' => $userId, + 'expiry' => $expiredAt, + ] + ); + + $this->assertEquals(200, $expireServer['headers']['status-code']); + $this->assertEquals($expiredAt, $expireServer['body']['expiry']); + + $createClient = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $this->getUser()['session'], + ], + [ + 'status' => 'online', + 'metadata' => ['test' => 'presence-expiry-client'], + ] + ); + + $this->assertEquals(200, $createClient['headers']['status-code']); + $presenceIdClient = $createClient['body']['$id']; + + $expireClient = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceIdClient, + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $this->getUser()['session'], + ], + [ + 'expiry' => $expiredAt, + ] + ); + + $this->assertEquals(200, $expireClient['headers']['status-code']); + $this->assertEquals($expiredAt, $expireClient['body']['expiry']); + + $stdout = ''; + $stderr = ''; + $code = Console::execute('docker exec appwrite maintenance --type=trigger', '', $stdout, $stderr); + $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); + + $this->assertEventually(function () use ($presenceIdServer, $presenceIdClient, $projectId) { + $getServer = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceIdServer, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ] + ); + + $getClient = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceIdClient, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ] + ); + + $this->assertEquals(404, $getServer['headers']['status-code']); + $this->assertEquals(404, $getClient['headers']['status-code']); + }); + } +}