diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 06312d9cb2..bab9aadcd3 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; +use Appwrite\Platform\Modules\Presences; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -29,6 +30,7 @@ class Appwrite extends Platform $this->addModule(new Avatars\Module()); $this->addModule(new Databases\Module()); $this->addModule(new Projects\Module()); + $this->addModule(new Presences\Module()); $this->addModule(new Functions\Module()); $this->addModule(new Health\Module()); $this->addModule(new Sites\Module()); diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Action.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Action.php new file mode 100644 index 0000000000..c954e7463f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Action.php @@ -0,0 +1,64 @@ +getRoles()); + $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + + $allowedPermissions = [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + Database::PERMISSION_WRITE, + ]; + + $permissions = Permission::aggregate($permissions, $allowedPermissions); + + // Add permissions for current the user if none were provided. + if (\is_null($permissions)) { + $permissions = []; + if (!empty($user->getId()) && !$isPrivilegedUser) { + foreach ($allowedPermissions as $permission) { + $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); + } + } + } + + // Users can only manage their own roles, API keys and Admin users can manage any + if (!$isAPIKey && !$isPrivilegedUser) { + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!$authorization->hasRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $authorization->getRoles()) . ')'); + } + } + } + } + + $document->setAttribute('$permissions', $permissions ?? []); + + return $document; + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php new file mode 100644 index 0000000000..f6af01de58 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php @@ -0,0 +1,66 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Delete presence') + ->groups(['api', 'presences']) + ->label('scope', 'users.write') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'delete', + description: 'Delete a presence log by its unique ID.', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ), + ], + contentType: ContentType::NONE, + )) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $presenceId, Response $response, Database $dbForProject): void + { + $presence = $dbForProject->getDocument('presenceLogs', $presenceId); + + if ($presence->isEmpty()) { + throw new Exception(Exception::DOCUMENT_NOT_FOUND); + } + + $dbForProject->deleteDocument('presenceLogs', $presenceId); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php new file mode 100644 index 0000000000..240a2f7bf4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php @@ -0,0 +1,62 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Get presence') + ->groups(['api', 'presences']) + ->label('scope', 'users.read') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'get', + description: 'Get a presence log by its unique ID.', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + )) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $presenceId, Response $response, Database $dbForProject): void + { + $presence = $dbForProject->getDocument('presenceLogs', $presenceId); + + if ($presence->isEmpty()) { + throw new Exception(Exception::DOCUMENT_NOT_FOUND); + } + + $response->dynamic($presence, Response::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php new file mode 100644 index 0000000000..a4f3169d1a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php @@ -0,0 +1,126 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Update presence') + ->groups(['api', 'presences']) + ->label('scope', 'users.write') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'update', + description: 'Update a presence log by its unique ID.', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + )) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->param('userId', null, new Nullable(new UID()), 'User ID.', true) + ->param('status', null, new Nullable(new Text(Database::LENGTH_KEY)), 'Presence status.', true) + ->param('expiry', null, new Nullable(new DatetimeValidator()), 'Presence expiry datetime.', true) + ->param('metadata', null, new Nullable(new JSON()), 'Presence metadata object.', true) + ->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) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $presenceId, + ?string $userId, + ?string $status, + ?string $expiry, + ?array $metadata, + ?array $permissions, + Response $response, + Database $dbForProject, + Document $user, + Authorization $authorization + ): void { + $isAPIKey = User::isApp($authorization->getRoles()); + $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + + if ($userId && !$isAPIKey && !$isPrivilegedUser) { + 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()) { + throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$presenceId]); + } + + $updateData = []; + + if ($userId !== null) { + $updateData['userId'] = $userId; + } + + if ($status !== null) { + $updateData['status'] = $status; + } + + if ($expiry !== null) { + $updateData['expiry'] = $expiry; + } + + if ($metadata !== null) { + $updateData['metadata'] = $metadata; + } + + $updates = new Document($updateData); + + if ($permissions !== null) { + $this->setPermission($updates, $permissions, $user, $authorization); + } + + if (empty($updateData) && $permissions === null) { + $response->dynamic($presence, Response::MODEL_PRESENCE); + return; + } + + $presence = $dbForProject->updateDocument('presenceLogs', $presenceId, $updates); + + $response->dynamic($presence, Response::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php new file mode 100644 index 0000000000..7faba3688a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php @@ -0,0 +1,135 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/presences/:presenceId') + ->desc('Upsert presence') + ->groups(['api', 'presences']) + ->label('scope', 'users.write') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'upsert', + description: 'Create or update a presence log by its unique ID.', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE, + ), + ], + )) + ->param('presenceId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Presence unique ID.', false, ['dbForProject']) + ->param('userId', '', new UID(), 'User ID.', false) + ->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 + ->param('expiry', null, new Nullable(new DatetimeValidator(requireDateInFuture: true)), 'Presence expiry datetime.', true) + ->param('metadata', [], new JSON(), 'Presence metadata object.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $presenceId, + ?string $userId, + ?string $status, + ?array $permissions, + ?string $expiry, + array $metadata, + Response $response, + Database $dbForProject, + Document $user, + Authorization $authorization + ): void { + $isAPIKey = User::isApp($authorization->getRoles()); + $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + if ($userId && !$isAPIKey && !$isPrivilegedUser) { + 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"); + } + $userInternalId = null; + $resolvedUserId = $userId; + if (!$isAPIKey && !$isPrivilegedUser) { + $userInternalId = $user->getId(); + $resolvedUserId = $user->getId(); + } else { + $user = $dbForProject->getDocument('users', $userId); + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND, params: [$userId]); + } + + $userInternalId = $user->getId(); + $resolvedUserId = $user->getId(); + } + + $presenceData = [ + 'userInternalId' => $userInternalId, + 'userId' => $resolvedUserId, + 'status' => $status, + 'source' => 'HTTP', + 'expiry' => $expiry ?? DateTime::addSeconds(new \DateTime(), 15 * 60), + // TODO: finding a way to find hostname + // 'hostname' => $hostname, + 'metadata' => $metadata, + ]; + + $presenceDocument = new Document($presenceData); + $this->setPermission($presenceDocument, $permissions, $user, $authorization); + + $existingPresence = $dbForProject->findOne('presenceLogs', [ + Query::equal('userId', [$resolvedUserId]), + ]); + + if ($existingPresence->isEmpty()) { + $presenceId = $presenceId === 'unique()' ? ID::unique() : $presenceId; + $presenceDocument->setAttribute('$id', $presenceId); + $presence = $dbForProject->createDocument('presenceLogs', $presenceDocument); + } else { + $presence = $dbForProject->updateDocument('presenceLogs', $existingPresence->getId(), $presenceDocument); + } + + $response->dynamic($presence, Response::MODEL_PRESENCE); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php new file mode 100644 index 0000000000..aab3958bdb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/presences') + ->desc('List presences') + ->groups(['api', 'presences']) + ->label('scope', 'users.read') + ->label('sdk', new Method( + namespace: 'presences', + group: 'presences', + name: 'list', + description: 'List presence logs.', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PRESENCE_LIST, + ), + ], + )) + ->param('queries', [], new QueryValidator('presenceLogs', $allowedAttributes), 'Array of query strings generated using the Query class provided by the SDK.', 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, bool $includeTotal, Response $response, Database $dbForProject): void + { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $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()); + } + + $presenceId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('presenceLogs', $presenceId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Presence '{$presenceId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $documents = $dbForProject->find('presenceLogs', $queries); + $total = $includeTotal ? $dbForProject->count('presenceLogs', $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([ + 'presences' => $documents, + 'total' => $total, + ]), Response::MODEL_PRESENCE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/Module.php b/src/Appwrite/Platform/Modules/Presences/Module.php new file mode 100644 index 0000000000..26be38e58c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Presences/Services/Http.php b/src/Appwrite/Platform/Modules/Presences/Services/Http.php new file mode 100644 index 0000000000..76fcfdee75 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Presences/Services/Http.php @@ -0,0 +1,25 @@ +type = Service::TYPE_HTTP; + + $this + ->addAction(UpsertPresence::getName(), new UpsertPresence()) + ->addAction(GetPresence::getName(), new GetPresence()) + ->addAction(ListPresences::getName(), new ListPresences()) + ->addAction(UpdatePresence::getName(), new UpdatePresence()) + ->addAction(DeletePresence::getName(), new DeletePresence()); + } +}