From 01cc9bcd1f2f8855a3d223fa9a72e7adf0c44686 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 29 Jan 2026 21:35:42 +0530 Subject: [PATCH 01/15] Move project create & update APIs to Modules --- app/controllers/api/projects.php | 376 ------------------ app/controllers/api/teams.php | 22 +- .../Platform/Modules/Compute/Base.php | 26 ++ .../Functions/Http/Functions/Create.php | 8 +- .../Functions/Http/Functions/Update.php | 8 +- .../Functions/Http/Variables/Create.php | 8 +- .../Modules/Projects/Http/Projects/Action.php | 30 ++ .../Modules/Projects/Http/Projects/Create.php | 294 ++++++++++++++ .../Projects/Http/Projects/Team/Update.php | 107 +++++ .../Modules/Projects/Http/Projects/Update.php | 94 +++++ .../Modules/Projects/Services/Http.php | 6 + .../Modules/Sites/Http/Sites/Create.php | 8 +- .../Modules/Sites/Http/Sites/Update.php | 8 +- .../Modules/Sites/Http/Variables/Create.php | 8 +- 14 files changed, 565 insertions(+), 438 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 57ad3030d9..3cebc4fbb9 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -61,251 +61,6 @@ App::init() } }); -App::post('/v1/projects') - ->desc('Create project') - ->groups(['api', 'projects']) - ->label('audits.event', 'projects.create') - ->label('audits.resource', 'project/{response.$id}') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'create', - description: '/docs/references/projects/create.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') - ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') - ->param('teamId', '', new UID(), 'Team unique ID.') - ->param('region', System::getEnv('_APP_REGION', 'default'), new Whitelist(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) - ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) - ->param('logo', '', new Text(1024), 'Project logo.', true) - ->param('url', '', new URL(), 'Project URL.', true) - ->param('legalName', '', new Text(256), 'Project legal Name. Max length: 256 chars.', true) - ->param('legalCountry', '', new Text(256), 'Project legal Country. Max length: 256 chars.', true) - ->param('legalState', '', new Text(256), 'Project legal State. Max length: 256 chars.', true) - ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true) - ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true) - ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true) - ->inject('request') - ->inject('response') - ->inject('dbForPlatform') - ->inject('cache') - ->inject('pools') - ->inject('hooks') - ->action(function (string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) { - - $team = $dbForPlatform->getDocument('teams', $teamId); - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - - $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); - - if (!empty($allowList) && !\in_array($region, $allowList)) { - throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); - } - - $auth = Config::getParam('auth', []); - $auths = [ - 'limit' => 0, - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, - 'passwordHistory' => 0, - 'passwordDictionary' => false, - 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, - 'personalDataCheck' => false, - 'mockNumbers' => [], - 'sessionAlerts' => false, - 'membershipsUserName' => false, - 'membershipsUserEmail' => false, - 'membershipsMfa' => false, - 'invalidateSessions' => true - ]; - - foreach ($auth as $method) { - $auths[$method['key'] ?? ''] = true; - } - - $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; - - if ($projectId === 'console') { - throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); - } - - $databases = Config::getParam('pools-database', []); - - if ($region !== 'default') { - $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); - $keys = explode(',', $databaseKeys); - $databases = array_filter($keys, function ($value) use ($region) { - return str_contains($value, $region); - }); - } - - $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); - $index = \array_search($databaseOverride, $databases); - if ($index !== false) { - $dsn = $databases[$index]; - } else { - $dsn = $databases[array_rand($databases)]; - } - - // TODO: Temporary until all projects are using shared tables. - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn, $sharedTables)) { - $schema = 'appwrite'; - $database = 'appwrite'; - $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); - $dsn = $schema . '://' . $dsn . '?database=' . $database; - - if (!empty($namespace)) { - $dsn .= '&namespace=' . $namespace; - } - } - - try { - $project = $dbForPlatform->createDocument('projects', new Document([ - '$id' => $projectId, - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], - 'name' => $name, - 'teamInternalId' => $team->getSequence(), - 'teamId' => $team->getId(), - 'region' => $region, - 'description' => $description, - 'logo' => $logo, - 'url' => $url, - 'version' => APP_VERSION_STABLE, - 'legalName' => $legalName, - 'legalCountry' => $legalCountry, - 'legalState' => $legalState, - 'legalCity' => $legalCity, - 'legalAddress' => $legalAddress, - 'legalTaxId' => ID::custom($legalTaxId), - 'services' => new stdClass(), - 'platforms' => null, - 'oAuthProviders' => [], - 'webhooks' => null, - 'keys' => null, - 'auths' => $auths, - 'accessedAt' => DateTime::now(), - 'search' => implode(' ', [$projectId, $name]), - 'database' => $dsn, - 'labels' => [], - ])); - } catch (Duplicate) { - throw new Exception(Exception::PROJECT_ALREADY_EXISTS); - } - - try { - $dsn = new DSN($dsn); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $dsn); - } - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); - $projectTables = !\in_array($dsn->getHost(), $sharedTables); - $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); - $sharedTablesV2 = !$projectTables && !$sharedTablesV1; - $sharedTables = $sharedTablesV1 || $sharedTablesV2; - - if (!$sharedTablesV2) { - $adapter = new DatabasePool($pools->get($dsn->getHost())); - $dbForProject = new Database($adapter, $cache); - $dbForProject->setDatabase(APP_DATABASE); - - if ($sharedTables) { - $dbForProject - ->setSharedTables(true) - ->setTenant($sharedTablesV1 ? (int)$project->getSequence() : null) - ->setNamespace($dsn->getParam('namespace')); - } else { - $dbForProject - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - - $create = true; - - try { - $dbForProject->create(); - } catch (Duplicate) { - $create = false; - } - - if ($create || $projectTables) { - $adapter = new AdapterDatabase($dbForProject); - $audit = new Audit($adapter); - $audit->setup(); - } - - if (!$create && $sharedTablesV1) { - $adapter = new AdapterDatabase($dbForProject); - $attributes = $adapter->getAttributeDocuments(); - $indexes = $adapter->getIndexDocuments(); - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom('audit'), - '$permissions' => [Permission::create(Role::any())], - 'name' => 'audit', - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } - - if ($create || $sharedTablesV1) { - /** @var array $collections */ - $collections = Config::getParam('collections', [])['projects'] ?? []; - - foreach ($collections as $key => $collection) { - if (($collection['$collection'] ?? '') !== Database::METADATA) { - continue; - } - - $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); - $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); - - try { - $dbForProject->createCollection($key, $attributes, $indexes); - } catch (Duplicate) { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } - } - } - } - - // Hook allowing instant project mirroring during migration - // Outside of migration, hook is not registered and has no effect - $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($project, Response::MODEL_PROJECT); - }); - App::get('/v1/projects/:projectId') ->desc('Get project') ->groups(['api', 'projects']) @@ -337,137 +92,6 @@ App::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -App::patch('/v1/projects/:projectId') - ->desc('Update project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('audits.event', 'projects.update') - ->label('audits.resource', 'project/{request.projectId}') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'update', - description: '/docs/references/projects/update.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') - ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) - ->param('logo', '', new Text(1024), 'Project logo.', true) - ->param('url', '', new URL(), 'Project URL.', true) - ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) - ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) - ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) - ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) - ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) - ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('name', $name) - ->setAttribute('description', $description) - ->setAttribute('logo', $logo) - ->setAttribute('url', $url) - ->setAttribute('legalName', $legalName) - ->setAttribute('legalCountry', $legalCountry) - ->setAttribute('legalState', $legalState) - ->setAttribute('legalCity', $legalCity) - ->setAttribute('legalAddress', $legalAddress) - ->setAttribute('legalTaxId', $legalTaxId) - ->setAttribute('search', implode(' ', [$projectId, $name]))); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -App::patch('/v1/projects/:projectId/team') - ->desc('Update project team') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateTeam', - description: '/docs/references/projects/update-team.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('teamId', '', new UID(), 'Team ID of the team to transfer project to.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $teamId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - $team = $dbForPlatform->getDocument('teams', $teamId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - - $permissions = [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ]; - - $project - ->setAttribute('teamId', $teamId) - ->setAttribute('teamInternalId', $team->getSequence()) - ->setAttribute('$permissions', $permissions); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); - - $installations = $dbForPlatform->find('installations', [ - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - foreach ($installations as $installation) { - $installation->getAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); - } - - $repositories = $dbForPlatform->find('repositories', [ - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - foreach ($repositories as $repository) { - $repository->getAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository); - } - - $vcsComments = $dbForPlatform->find('vcsComments', [ - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - foreach ($vcsComments as $vcsComment) { - $vcsComment->getAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), $vcsComment); - } - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - App::patch('/v1/projects/:projectId/service') ->desc('Update service status') ->groups(['api', 'projects']) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 703582f3fd..c9e57cb353 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -484,16 +484,7 @@ App::post('/v1/teams/:teamId/memberships') ->param('email', '', new EmailValidator(), 'Email of the new team member.', true) ->param('userId', '', new UID(), 'ID of the user to be added to a team.', true) ->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) - ->param('roles', [], function (Document $project) { - if ($project->getId() === 'console') { - $roles = array_keys(Config::getParam('roles', [])); - $roles = array_filter($roles, function ($role) { - return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); - }); - return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); - } - return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); - }, 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) + ->param('roles', [], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) ->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // TODO add our own built-in confirm page ->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true) ->inject('response') @@ -1095,16 +1086,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId') )) ->param('teamId', '', new UID(), 'Team ID.') ->param('membershipId', '', new UID(), 'Membership ID.') - ->param('roles', [], function (Document $project) { - if ($project->getId() === 'console') { - $roles = array_keys(Config::getParam('roles', [])); - $roles = array_filter($roles, function ($role) { - return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); - }); - return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); - } - return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); - }, 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) + ->param('roles', [], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) ->inject('request') ->inject('response') ->inject('user') diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 749a9fe87a..f0dcb1a4ff 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -22,6 +22,32 @@ use Utopia\VCS\Exception\RepositoryNotFound; class Base extends Action { + /** + * Permissions for resources in this project. + * + * @param string $teamId + * @param string $projectId + * @return string[] + */ + protected function getPermissions(string $teamId, string $projectId): array + { + return [ + // Team-wide permissions + Permission::read(Role::team(ID::custom($teamId), 'owner')), + Permission::read(Role::team(ID::custom($teamId), 'developer')), + Permission::update(Role::team(ID::custom($teamId), 'owner')), + Permission::update(Role::team(ID::custom($teamId), 'developer')), + Permission::delete(Role::team(ID::custom($teamId), 'owner')), + Permission::delete(Role::team(ID::custom($teamId), 'developer')), + // Project-wide permissions + Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}")), + Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), + Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), + Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), + Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), + ]; + } + /** * Get default specification based on plan and available specifications. * diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 4bf072d115..41adfe283b 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -265,13 +265,7 @@ class Create extends Base $repository = $dbForPlatform->createDocument('repositories', new Document([ '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], + '$permissions' => $this->getPermissions($teamId, $project->getId()), 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'projectId' => $project->getId(), diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index e5ff11864a..7a7d4c098a 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -202,13 +202,7 @@ class Update extends Base $repository = $dbForPlatform->createDocument('repositories', new Document([ '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], + '$permissions' => $this->getPermissions($teamId, $project->getId()), 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'projectId' => $project->getId(), diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php index 5438479d40..6cf54765cd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php @@ -91,13 +91,7 @@ class Create extends Base $teamId = $project->getAttribute('teamId', ''); $variable = new Document([ '$id' => $variableId, - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], + '$permissions' => $this->getPermissions($teamId, $project->getId()), 'resourceInternalId' => $function->getSequence(), 'resourceId' => $function->getId(), 'resourceType' => 'function', diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php new file mode 100644 index 0000000000..1b38fa01f4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php @@ -0,0 +1,30 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/projects') + ->desc('Create project') + ->groups(['api', 'projects']) + ->label('audits.event', 'projects.create') + ->label('audits.resource', 'project/{response.$id}') + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'create', + description: '/docs/references/projects/create.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('teamId', '', new UID(), 'Team unique ID.') + ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) + ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) + ->param('logo', '', new Text(1024), 'Project logo.', true) + ->param('url', '', new URL(), 'Project URL.', true) + ->param('legalName', '', new Text(256), 'Project legal Name. Max length: 256 chars.', true) + ->param('legalCountry', '', new Text(256), 'Project legal Country. Max length: 256 chars.', true) + ->param('legalState', '', new Text(256), 'Project legal State. Max length: 256 chars.', true) + ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true) + ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true) + ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true) + ->inject('request') + ->inject('response') + ->inject('dbForPlatform') + ->inject('cache') + ->inject('pools') + ->inject('hooks') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) + { + $team = $dbForPlatform->getDocument('teams', $teamId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); + + if (!empty($allowList) && !\in_array($region, $allowList)) { + throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); + } + + $auth = Config::getParam('auth', []); + $auths = [ + 'limit' => 0, + 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'passwordHistory' => 0, + 'passwordDictionary' => false, + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, + 'personalDataCheck' => false, + 'mockNumbers' => [], + 'sessionAlerts' => false, + 'membershipsUserName' => false, + 'membershipsUserEmail' => false, + 'membershipsMfa' => false, + 'invalidateSessions' => true + ]; + + foreach ($auth as $method) { + $auths[$method['key'] ?? ''] = true; + } + + $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; + + if ($projectId === 'console') { + throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); + } + + $databases = Config::getParam('pools-database', []); + + if ($region !== 'default') { + $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); + $keys = explode(',', $databaseKeys); + $databases = array_filter($keys, function ($value) use ($region) { + return str_contains($value, $region); + }); + } + + $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); + $index = \array_search($databaseOverride, $databases); + if ($index !== false) { + $dsn = $databases[$index]; + } else { + $dsn = $databases[array_rand($databases)]; + } + + // TODO: Temporary until all projects are using shared tables. + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn, $sharedTables)) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $dsn = $schema . '://' . $dsn . '?database=' . $database; + + if (!empty($namespace)) { + $dsn .= '&namespace=' . $namespace; + } + } + + try { + $project = $dbForPlatform->createDocument('projects', new Document([ + '$id' => $projectId, + '$permissions' => $this->getPermissions($teamId, $projectId), + 'name' => $name, + 'teamInternalId' => $team->getSequence(), + 'teamId' => $team->getId(), + 'region' => $region, + 'description' => $description, + 'logo' => $logo, + 'url' => $url, + 'version' => APP_VERSION_STABLE, + 'legalName' => $legalName, + 'legalCountry' => $legalCountry, + 'legalState' => $legalState, + 'legalCity' => $legalCity, + 'legalAddress' => $legalAddress, + 'legalTaxId' => ID::custom($legalTaxId), + 'services' => new \stdClass(), + 'platforms' => null, + 'oAuthProviders' => [], + 'webhooks' => null, + 'keys' => null, + 'auths' => $auths, + 'accessedAt' => DateTime::now(), + 'search' => implode(' ', [$projectId, $name]), + 'database' => $dsn, + 'labels' => [], + ])); + } catch (Duplicate) { + throw new Exception(Exception::PROJECT_ALREADY_EXISTS); + } + + try { + $dsn = new DSN($dsn); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $dsn); + } + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); + $projectTables = !\in_array($dsn->getHost(), $sharedTables); + $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); + $sharedTablesV2 = !$projectTables && !$sharedTablesV1; + $sharedTables = $sharedTablesV1 || $sharedTablesV2; + + if (!$sharedTablesV2) { + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $dbForProject = new Database($adapter, $cache); + $dbForProject->setDatabase(APP_DATABASE); + + if ($sharedTables) { + $dbForProject + ->setSharedTables(true) + ->setTenant($sharedTablesV1 ? (int)$project->getSequence() : null) + ->setNamespace($dsn->getParam('namespace')); + } else { + $dbForProject + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + + $create = true; + + try { + $dbForProject->create(); + } catch (Duplicate) { + $create = false; + } + + if ($create || $projectTables) { + $adapter = new AdapterDatabase($dbForProject); + $audit = new Audit($adapter); + $audit->setup(); + } + + if (!$create && $sharedTablesV1) { + $adapter = new AdapterDatabase($dbForProject); + $attributes = $adapter->getAttributeDocuments(); + $indexes = $adapter->getIndexDocuments(); + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom('audit'), + '$permissions' => [Permission::create(Role::any())], + 'name' => 'audit', + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } + + if ($create || $sharedTablesV1) { + /** @var array $collections */ + $collections = Config::getParam('collections', [])['projects'] ?? []; + + foreach ($collections as $key => $collection) { + if (($collection['$collection'] ?? '') !== Database::METADATA) { + continue; + } + + $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); + + try { + $dbForProject->createCollection($key, $attributes, $indexes); + } catch (Duplicate) { + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom($key), + '$permissions' => [Permission::create(Role::any())], + 'name' => $key, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } + } + } + } + + // Hook allowing instant project mirroring during migration + // Outside of migration, hook is not registered and has no effect + $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($project, Response::MODEL_PROJECT); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php new file mode 100644 index 0000000000..ac1537cd3a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/projects/:projectId/team') + ->desc('Update project team') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'updateTeam', + description: '/docs/references/projects/update-team.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('teamId', '', new UID(), 'Team ID of the team to transfer project to.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $teamId, Response $response, Database $dbForPlatform) + { + $project = $dbForPlatform->getDocument('projects', $projectId); + $team = $dbForPlatform->getDocument('teams', $teamId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $permissions = $this->getPermissions($teamId, $projectId); + + $project + ->setAttribute('teamId', $teamId) + ->setAttribute('teamInternalId', $team->getSequence()) + ->setAttribute('$permissions', $permissions); + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); + + $installations = $dbForPlatform->find('installations', [ + Query::equal('projectInternalId', [$project->getSequence()]), + ]); + foreach ($installations as $installation) { + $installation->setAttribute('$permissions', $permissions); + $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); + } + + $repositories = $dbForPlatform->find('repositories', [ + Query::equal('projectInternalId', [$project->getSequence()]), + ]); + foreach ($repositories as $repository) { + $repository->setAttribute('$permissions', $permissions); + $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository); + } + + $vcsComments = $dbForPlatform->find('vcsComments', [ + Query::equal('projectInternalId', [$project->getSequence()]), + ]); + foreach ($vcsComments as $vcsComment) { + $vcsComment->setAttribute('$permissions', $permissions); + $dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), $vcsComment); + } + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php new file mode 100644 index 0000000000..2ec0fd9501 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php @@ -0,0 +1,94 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/projects/:projectId') + ->desc('Update project') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('audits.event', 'projects.update') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'update', + description: '/docs/references/projects/update.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) + ->param('logo', '', new Text(1024), 'Project logo.', true) + ->param('url', '', new URL(), 'Project URL.', true) + ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) + ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) + ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) + ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) + ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) + ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('name', $name) + ->setAttribute('description', $description) + ->setAttribute('logo', $logo) + ->setAttribute('url', $url) + ->setAttribute('legalName', $legalName) + ->setAttribute('legalCountry', $legalCountry) + ->setAttribute('legalState', $legalState) + ->setAttribute('legalCity', $legalCity) + ->setAttribute('legalAddress', $legalAddress) + ->setAttribute('legalTaxId', $legalTaxId) + ->setAttribute('search', implode(' ', [$projectId, $name]))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index cce05a9570..b4617fdb76 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -7,7 +7,10 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Delete as DeleteDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; +use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject; +use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Utopia\Platform\Service; @@ -22,7 +25,10 @@ class Http extends Service $this->addAction(ListDevKeys::getName(), new ListDevKeys()); $this->addAction(DeleteDevKey::getName(), new DeleteDevKey()); + $this->addAction(CreateProject::getName(), new CreateProject()); + $this->addAction(UpdateProject::getName(), new UpdateProject()); $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index b48cfeb73f..c5532ea9cf 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -176,13 +176,7 @@ class Create extends Base $repository = $dbForPlatform->createDocument('repositories', new Document([ '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], + '$permissions' => $this->getPermissions($teamId, $project->getId()), 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'projectId' => $project->getId(), diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index b4b720537d..9197acbef1 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -198,13 +198,7 @@ class Update extends Base $repository = $dbForPlatform->createDocument('repositories', new Document([ '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], + '$permissions' => $this->getPermissions($teamId, $project->getId()), 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getSequence(), 'projectId' => $project->getId(), diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php index c674aa06a2..62ca69b7d0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php @@ -78,13 +78,7 @@ class Create extends Base $teamId = $project->getAttribute('teamId', ''); $variable = new Document([ '$id' => $variableId, - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], + '$permissions' => $this->getPermissions($teamId, $project->getId()), 'resourceInternalId' => $site->getSequence(), 'resourceId' => $site->getId(), 'resourceType' => 'site', From cb0f2299fb39be150eb3c4ea25929e64cee5cfe4 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Fri, 30 Jan 2026 13:58:18 +0530 Subject: [PATCH 02/15] Upgrade DB --- composer.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.lock b/composer.lock index c29c66e759..4606767abb 100644 --- a/composer.lock +++ b/composer.lock @@ -3961,16 +3961,16 @@ }, { "name": "utopia-php/database", - "version": "4.6.1", + "version": "4.6.4", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "8795a7f5bf8828955299ae44e5946f93a2b1bde5" + "reference": "4dfffd4d528f89b3b3fc09180d4c965ef9bdae30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/8795a7f5bf8828955299ae44e5946f93a2b1bde5", - "reference": "8795a7f5bf8828955299ae44e5946f93a2b1bde5", + "url": "https://api.github.com/repos/utopia-php/database/zipball/4dfffd4d528f89b3b3fc09180d4c965ef9bdae30", + "reference": "4dfffd4d528f89b3b3fc09180d4c965ef9bdae30", "shasum": "" }, "require": { @@ -4013,9 +4013,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/4.6.1" + "source": "https://github.com/utopia-php/database/tree/4.6.4" }, - "time": "2026-01-21T09:37:22+00:00" + "time": "2026-01-30T08:19:14+00:00" }, { "name": "utopia-php/detector", @@ -9076,5 +9076,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From 2144f2705906f6d150c73c86d17024b27250f211 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Fri, 30 Jan 2026 14:36:38 +0530 Subject: [PATCH 03/15] lint + feedback --- app/controllers/api/projects.php | 12 ------------ app/controllers/api/teams.php | 1 - .../Modules/Functions/Http/Functions/Update.php | 2 -- .../Modules/Functions/Http/Variables/Create.php | 2 -- .../Modules/Projects/Http/Projects/Action.php | 3 ++- .../Modules/Projects/Http/Projects/Create.php | 3 ++- .../Modules/Projects/Http/Projects/Team/Update.php | 2 +- .../Modules/Projects/Http/Projects/Update.php | 2 +- .../Platform/Modules/Projects/Services/Http.php | 2 +- .../Platform/Modules/Sites/Http/Sites/Create.php | 2 -- .../Platform/Modules/Sites/Http/Sites/Update.php | 2 -- .../Platform/Modules/Sites/Http/Variables/Create.php | 2 -- 12 files changed, 7 insertions(+), 28 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 3cebc4fbb9..776a9a7f32 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -6,7 +6,6 @@ use Appwrite\Event\Delete; use Appwrite\Event\Mail; use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; -use Appwrite\Hooks\Hooks; use Appwrite\Network\Platform; use Appwrite\Network\Validator\Email; use Appwrite\SDK\AuthType; @@ -15,21 +14,12 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; -use Appwrite\Utopia\Database\Validator\ProjectId; -use Appwrite\Utopia\Database\Validator\Queries\Projects; -use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\App; -use Utopia\Audit\Adapter\Database as AdapterDatabase; -use Utopia\Audit\Audit; -use Utopia\Cache\Cache; use Utopia\Config\Config; -use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; -use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -37,9 +27,7 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; use Utopia\Domains\Validator\PublicDomain; -use Utopia\DSN\DSN; use Utopia\Locale\Locale; -use Utopia\Pools\Group; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index c9e57cb353..3ebb8ba918 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -58,7 +58,6 @@ use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -use Utopia\Validator\WhiteList; App::post('/v1/teams') ->desc('Create team') diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 7a7d4c098a..3df257726a 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -19,8 +19,6 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Roles; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php index 6cf54765cd..99d9f49a66 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Create.php @@ -13,8 +13,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php index 1b38fa01f4..3b31618440 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php @@ -20,7 +20,8 @@ class Action extends AppwriteAction Permission::delete(Role::team(ID::custom($teamId), 'owner')), Permission::delete(Role::team(ID::custom($teamId), 'developer')), // Project-wide permissions - Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}")), + Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), + Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index 424d6d1344..d22cf03590 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -19,6 +19,7 @@ use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -291,4 +292,4 @@ class Create extends Action ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($project, Response::MODEL_PROJECT); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php index ac1537cd3a..df5b2b6245 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php @@ -104,4 +104,4 @@ class Update extends Action $response->dynamic($project, Response::MODEL_PROJECT); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php index 2ec0fd9501..29c26b33ea 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php @@ -91,4 +91,4 @@ class Update extends Action $response->dynamic($project, Response::MODEL_PROJECT); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index b4617fdb76..587f101d61 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -8,8 +8,8 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject; -use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Utopia\Platform\Service; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php index c5532ea9cf..dd2c30625f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Create.php @@ -15,8 +15,6 @@ use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 9197acbef1..9cfa45b77b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -16,8 +16,6 @@ use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php index 62ca69b7d0..fe4fe35626 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Create.php @@ -12,8 +12,6 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; From a987195f6fee3a66089f086c5d55999aea9f690e Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Fri, 30 Jan 2026 14:47:55 +0530 Subject: [PATCH 04/15] more lint --- src/Appwrite/Platform/Modules/Compute/Base.php | 3 ++- .../Platform/Modules/Projects/Http/Projects/Action.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index f0dcb1a4ff..47c648283f 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -40,7 +40,8 @@ class Base extends Action Permission::delete(Role::team(ID::custom($teamId), 'owner')), Permission::delete(Role::team(ID::custom($teamId), 'developer')), // Project-wide permissions - Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}")), + Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), + Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php index 3b31618440..21cd108485 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php @@ -28,4 +28,4 @@ class Action extends AppwriteAction Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), ]; } -} \ No newline at end of file +} From f82662e84efde0dbaeccaf5c52d736980f8c9476 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 3 Feb 2026 18:33:35 +0530 Subject: [PATCH 05/15] added dot escaping fix for channel names --- src/Appwrite/Messaging/Adapter/Realtime.php | 2 +- .../RealtimeCustomClientQueryTest.php | 195 ++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 033297dd65..8f8d8ad0e8 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -361,7 +361,7 @@ class Realtime extends MessagingAdapter $subscriptionsByIndex = []; foreach ($channelNames as $channel) { - $channelSubscriptions = $getQueryParam($channel); + $channelSubscriptions = $getQueryParam(str_replace(".", "_", $channel)); // Backward compatibility: if no channel-specific query params, treat as subscription 0 with select("*") if ($channelSubscriptions === null) { diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index e93e955f1a..4f030386c8 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1234,6 +1234,183 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } + public function testCollectionScopedDocumentsChannelReceivesEvents() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Scoped Channel DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Scoped Channel Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + // Subscribe only to the fully-qualified documents channel for this collection + $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; + $client = $this->getWebsocket([$scopedChannel], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + $this->assertContains($scopedChannel, $response['data']['channels']); + + // Create document in that collection - should receive event on the scoped channel + $documentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($documentId, $event['data']['payload']['$id']); + + $client->close(); + } + + public function testCollectionScopedDocumentsChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Scoped Channel Query DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Scoped Channel Query Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID on the fully-qualified documents channel + $scopedChannel = 'databases.' . $databaseId . '.collections.' . $collectionId . '.documents'; + $client = $this->getWebsocket([$scopedChannel], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + $this->assertContains($scopedChannel, $response['data']['channels']); + + // Create document with matching ID - should receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocumentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + public function testFilesChannelWithQuery() { $user = $this->getUser(); @@ -2085,4 +2262,22 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } + + public function testConsole() + { + $this->client->call(Client::METHOD_POST, '/databases/' . '6981e806000e18b050be' . '/collections/' . 'kv' . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => '6981e7dc0003192f3424', + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'key' => 'key', + 'value' => 'value' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + } } From c3fbb83ed6b4198d0bf01b12cb450122ce0e9e4d Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Wed, 4 Feb 2026 20:03:52 +0530 Subject: [PATCH 06/15] Move VCS installation APIs to Modules --- app/controllers/api/vcs.php | 157 ------------------ app/init/constants.php | 1 + .../Modules/VCS/Http/Installations/Delete.php | 78 +++++++++ .../Modules/VCS/Http/Installations/Get.php | 72 ++++++++ .../Modules/VCS/Http/Installations/XList.php | 120 +++++++++++++ src/Appwrite/Platform/Modules/VCS/Module.php | 14 ++ .../Platform/Modules/VCS/Services/Http.php | 13 ++ 7 files changed, 298 insertions(+), 157 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Module.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Services/Http.php diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index 72b996f7eb..373f413304 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -2,14 +2,12 @@ use Appwrite\Auth\OAuth2\Github as OAuth2Github; use Appwrite\Event\Build; -use Appwrite\Event\Delete; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\MethodType; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Utopia\Database\Validator\Queries\Installations; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Appwrite\Vcs\Comment; @@ -22,15 +20,12 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; -use Utopia\Database\Exception\Order as OrderException; -use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Queries; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Detector\Detection\Framework\Analog; @@ -1612,158 +1607,6 @@ Http::post('/v1/vcs/github/events') } ); -Http::get('/v1/vcs/installations') - ->desc('List installations') - ->groups(['api', 'vcs']) - ->label('scope', 'vcs.read') - ->label('sdk', new Method( - namespace: 'vcs', - group: 'installations', - name: 'listInstallations', - description: '/docs/references/vcs/list-installations.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_INSTALLATION_LIST, - ) - ] - )) - ->param('queries', [], new Installations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Installations::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Database $dbForPlatform) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $installationId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('installations', $installationId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Installation '{$installationId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - try { - $results = $dbForPlatform->find('installations', $queries); - $total = $includeTotal ? $dbForPlatform->count('installations', $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([ - 'installations' => $results, - 'total' => $total, - ]), Response::MODEL_INSTALLATION_LIST); - }); - -Http::get('/v1/vcs/installations/:installationId') - ->desc('Get installation') - ->groups(['api', 'vcs']) - ->label('scope', 'vcs.read') - ->label('sdk', new Method( - namespace: 'vcs', - group: 'installations', - name: 'getInstallation', - description: '/docs/references/vcs/get-installation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_INSTALLATION, - ) - ] - )) - ->param('installationId', '', new Text(256), 'Installation Id') - ->inject('response') - ->inject('project') - ->inject('dbForPlatform') - ->action(function (string $installationId, Response $response, Document $project, Database $dbForPlatform) { - $installation = $dbForPlatform->getDocument('installations', $installationId); - - if ($installation === false || $installation->isEmpty()) { - throw new Exception(Exception::INSTALLATION_NOT_FOUND); - } - - if ($installation->getAttribute('projectInternalId') !== $project->getSequence()) { - throw new Exception(Exception::INSTALLATION_NOT_FOUND); - } - - $response->dynamic($installation, Response::MODEL_INSTALLATION); - }); - -Http::delete('/v1/vcs/installations/:installationId') - ->desc('Delete installation') - ->groups(['api', 'vcs']) - ->label('scope', 'vcs.write') - ->label('sdk', new Method( - namespace: 'vcs', - group: 'installations', - name: 'deleteInstallation', - description: '/docs/references/vcs/delete-installation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('installationId', '', new Text(256), 'Installation Id') - ->inject('response') - ->inject('project') - ->inject('dbForPlatform') - ->inject('queueForDeletes') - ->action(function (string $installationId, Response $response, Document $project, Database $dbForPlatform, Delete $queueForDeletes) { - $installation = $dbForPlatform->getDocument('installations', $installationId); - - if ($installation->isEmpty()) { - throw new Exception(Exception::INSTALLATION_NOT_FOUND); - } - - if (!$dbForPlatform->deleteDocument('installations', $installation->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove installation from DB'); - } - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($installation); - - $response->noContent(); - }); - Http::patch('/v1/vcs/github/installations/:installationId/repositories/:repositoryId') ->desc('Update external deployment (authorize)') ->groups(['api', 'vcs']) diff --git a/app/init/constants.php b/app/init/constants.php index 7912cb823a..0ee5271d7f 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -369,6 +369,7 @@ const RESOURCE_TYPE_TOPICS = 'topics'; const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers'; const RESOURCE_TYPE_MESSAGES = 'messages'; const RESOURCE_TYPE_EXECUTIONS = 'executions'; +const RESOURCE_TYPE_VCS = 'vcs'; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php new file mode 100644 index 0000000000..4734878a58 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vcs/installations/:installationId') + ->desc('Delete installation') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.write') + ->label('resourceType', RESOURCE_TYPE_VCS) + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'deleteInstallation', + description: '/docs/references/vcs/delete-installation.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('installationId', '', new Text(256), 'Installation Id') + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForDeletes') + ->callback($this->action(...)); + } + + public function action( + string $installationId, + Response $response, + Database $dbForPlatform, + DeleteEvent $queueForDeletes + ) { + $installation = $dbForPlatform->getDocument('installations', $installationId); + + if ($installation->isEmpty()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + if (!$dbForPlatform->deleteDocument('installations', $installation->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove installation from DB'); + } + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($installation); + + $response->noContent(); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php new file mode 100644 index 0000000000..309404542a --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php @@ -0,0 +1,72 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/installations/:installationId') + ->desc('Get installation') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.read') + ->label('resourceType', RESOURCE_TYPE_VCS) + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'getInstallation', + description: '/docs/references/vcs/get-installation.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_INSTALLATION, + ) + ] + )) + ->param('installationId', '', new Text(256), 'Installation Id') + ->inject('response') + ->inject('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $installationId, + Response $response, + Document $project, + Database $dbForPlatform + ) { + $installation = $dbForPlatform->getDocument('installations', $installationId); + + if ($installation === false || $installation->isEmpty()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + if ($installation->getAttribute('projectInternalId') !== $project->getSequence()) { + throw new Exception(Exception::INSTALLATION_NOT_FOUND); + } + + $response->dynamic($installation, Response::MODEL_INSTALLATION); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php new file mode 100644 index 0000000000..1ededf8d57 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php @@ -0,0 +1,120 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/installations') + ->desc('List installations') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.read') + ->label('resourceType', RESOURCE_TYPE_VCS) + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'listInstallations', + description: '/docs/references/vcs/list-installations.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_INSTALLATION_LIST, + ) + ] + )) + ->param('queries', [], new Installations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Installations::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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('project') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + array $queries, + string $search, + bool $includeTotal, + Response $response, + Document $project, + Database $dbForPlatform + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + if ($cursor) { + /** @var Query $cursor */ + + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $installationId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('installations', $installationId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Installation '{$installationId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + try { + $results = $dbForPlatform->find('installations', $queries); + $total = $includeTotal ? $dbForPlatform->count('installations', $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([ + 'installations' => $results, + 'total' => $total, + ]), Response::MODEL_INSTALLATION_LIST); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/VCS/Module.php b/src/Appwrite/Platform/Modules/VCS/Module.php new file mode 100644 index 0000000000..d16a96fd01 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/VCS/Services/Http.php b/src/Appwrite/Platform/Modules/VCS/Services/Http.php new file mode 100644 index 0000000000..ad23df5529 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Services/Http.php @@ -0,0 +1,13 @@ +type = Service::TYPE_HTTP; + } +} \ No newline at end of file From 26b04476573dca30cbfe3c513b54acf300fdac9c Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Wed, 4 Feb 2026 20:41:46 +0530 Subject: [PATCH 07/15] add to http --- src/Appwrite/Platform/Modules/VCS/Services/Http.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Appwrite/Platform/Modules/VCS/Services/Http.php b/src/Appwrite/Platform/Modules/VCS/Services/Http.php index ad23df5529..0cd0fc7d4c 100644 --- a/src/Appwrite/Platform/Modules/VCS/Services/Http.php +++ b/src/Appwrite/Platform/Modules/VCS/Services/Http.php @@ -2,6 +2,9 @@ namespace Appwrite\Platform\Modules\VCS\Services; +use Appwrite\Platform\Modules\VCS\Http\Installations\Get as GetInstallation; +use Appwrite\Platform\Modules\VCS\Http\Installations\Delete as DeleteInstallation; +use Appwrite\Platform\Modules\VCS\Http\Installations\XList as ListInstallations; use Utopia\Platform\Service; class Http extends Service @@ -9,5 +12,10 @@ class Http extends Service public function __construct() { $this->type = Service::TYPE_HTTP; + + // Installations + $this->addAction(GetInstallation::getName(), new GetInstallation()); + $this->addAction(ListInstallations::getName(), new ListInstallations()); + $this->addAction(DeleteInstallation::getName(), new DeleteInstallation()); } } \ No newline at end of file From 8c191fca92f6f662bde92e52ef86aaac10bae6a2 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 5 Feb 2026 12:44:19 +0530 Subject: [PATCH 08/15] wire module --- src/Appwrite/Platform/Appwrite.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 35347b4023..9982b0bf1e 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -14,6 +14,7 @@ use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Tokens; +use Appwrite\Platform\Modules\VCS; use Utopia\Platform\Platform; class Appwrite extends Platform @@ -32,5 +33,6 @@ class Appwrite extends Platform $this->addModule(new Proxy\Module()); $this->addModule(new Tokens\Module()); $this->addModule(new Storage\Module()); + $this->addModule(new VCS\Module()); } } From 7f824785d5c07995c81d3066c1509d6cf90eb050 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 5 Feb 2026 12:55:34 +0530 Subject: [PATCH 09/15] lint --- .../Platform/Modules/VCS/Http/Installations/Delete.php | 2 +- src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php | 2 +- .../Platform/Modules/VCS/Http/Installations/XList.php | 2 +- src/Appwrite/Platform/Modules/VCS/Module.php | 2 +- src/Appwrite/Platform/Modules/VCS/Services/Http.php | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php index 4734878a58..26a9476941 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Delete.php @@ -75,4 +75,4 @@ class Delete extends Action $response->noContent(); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php index 309404542a..7bb2dedaf5 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php @@ -69,4 +69,4 @@ class Get extends Action $response->dynamic($installation, Response::MODEL_INSTALLATION); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php index 1ededf8d57..628459fb27 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/XList.php @@ -117,4 +117,4 @@ class XList extends Action 'total' => $total, ]), Response::MODEL_INSTALLATION_LIST); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Platform/Modules/VCS/Module.php b/src/Appwrite/Platform/Modules/VCS/Module.php index d16a96fd01..9f43a83da9 100644 --- a/src/Appwrite/Platform/Modules/VCS/Module.php +++ b/src/Appwrite/Platform/Modules/VCS/Module.php @@ -11,4 +11,4 @@ class Module extends Platform\Module { $this->addService('http', new Http()); } -} \ No newline at end of file +} diff --git a/src/Appwrite/Platform/Modules/VCS/Services/Http.php b/src/Appwrite/Platform/Modules/VCS/Services/Http.php index 0cd0fc7d4c..3630a5b32f 100644 --- a/src/Appwrite/Platform/Modules/VCS/Services/Http.php +++ b/src/Appwrite/Platform/Modules/VCS/Services/Http.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\VCS\Services; -use Appwrite\Platform\Modules\VCS\Http\Installations\Get as GetInstallation; use Appwrite\Platform\Modules\VCS\Http\Installations\Delete as DeleteInstallation; +use Appwrite\Platform\Modules\VCS\Http\Installations\Get as GetInstallation; use Appwrite\Platform\Modules\VCS\Http\Installations\XList as ListInstallations; use Utopia\Platform\Service; @@ -18,4 +18,4 @@ class Http extends Service $this->addAction(ListInstallations::getName(), new ListInstallations()); $this->addAction(DeleteInstallation::getName(), new DeleteInstallation()); } -} \ No newline at end of file +} From 4aaa3bfd9fbd6560cc6b9214913c9ed61c45a869 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 5 Feb 2026 13:27:06 +0530 Subject: [PATCH 10/15] Cleanup functions & sites attribute when installation is deleted --- src/Appwrite/Platform/Workers/Deletes.php | 37 ++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index dc1775e8b0..a3662e91e6 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -974,7 +974,7 @@ class Deletes extends Action $deploymentIds = []; $this->deleteByGroup('deployments', [ Query::equal('resourceInternalId', [$siteInternalId]), - Query::equal('resourceType', ['site']), + Query::equal('resourceType', ['sites']), Query::orderAsc() ], $dbForProject, function (Document $document) use ($project, $certificates, $deviceForSites, $deviceForBuilds, $deviceForFiles, $dbForPlatform, &$deploymentInternalIds) { $deploymentInternalIds[] = $document->getSequence(); @@ -1062,7 +1062,7 @@ class Deletes extends Action $deploymentInternalIds = []; $this->deleteByGroup('deployments', [ Query::equal('resourceInternalId', [$functionInternalId]), - Query::equal('resourceType', ['function']), + Query::equal('resourceType', ['functions']), Query::orderAsc() ], $dbForProject, function (Document $document) use ($dbForPlatform, $project, $certificates, $deviceForFunctions, $deviceForBuilds, &$deploymentInternalIds) { $deploymentInternalIds[] = $document->getSequence(); @@ -1400,22 +1400,25 @@ class Deletes extends Action { $dbForProject = $getProjectDB($project); - $this->listByGroup('functions', [ - Query::equal('installationInternalId', [$document->getSequence()]) - ], $dbForProject, function ($function) use ($dbForProject, $dbForPlatform) { - $dbForPlatform->deleteDocument('repositories', $function->getAttribute('repositoryId')); + // Cleanup sites and functions + foreach (['sites', 'functions'] as $resource) { + $this->listByGroup($resource, [ + Query::equal('installationInternalId', [$document->getSequence()]) + ], $dbForProject, function ($document) use ($resource, $dbForProject, $dbForPlatform) { + $dbForPlatform->deleteDocument('repositories', $document->getAttribute('repositoryId')); - $function = $function - ->setAttribute('installationId', '') - ->setAttribute('installationInternalId', '') - ->setAttribute('providerRepositoryId', '') - ->setAttribute('providerBranch', '') - ->setAttribute('providerSilentMode', false) - ->setAttribute('providerRootDirectory', '') - ->setAttribute('repositoryId', '') - ->setAttribute('repositoryInternalId', ''); - $dbForProject->updateDocument('functions', $function->getId(), $function); - }); + $document = $document + ->setAttribute('installationId', '') + ->setAttribute('installationInternalId', '') + ->setAttribute('providerRepositoryId', '') + ->setAttribute('providerBranch', '') + ->setAttribute('providerSilentMode', false) + ->setAttribute('providerRootDirectory', '') + ->setAttribute('repositoryId', '') + ->setAttribute('repositoryInternalId', ''); + $dbForProject->updateDocument($resource, $document->getId(), $document); + }); + } } /** From aa30db213639b79bfff3f27b6076b2d300383e6f Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 5 Feb 2026 13:51:32 +0530 Subject: [PATCH 11/15] refactor --- src/Appwrite/Platform/Workers/Deletes.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index a3662e91e6..435ce56f83 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -1391,21 +1391,24 @@ class Deletes extends Action /** * @param Database $dbForPlatform * @param callable $getProjectDB - * @param Document $document + * @param Document $installation * @param Document $project * @return void * @throws Exception */ - private function deleteInstallation(Database $dbForPlatform, callable $getProjectDB, Document $document, Document $project): void + private function deleteInstallation(Database $dbForPlatform, callable $getProjectDB, Document $installation, Document $project): void { $dbForProject = $getProjectDB($project); // Cleanup sites and functions - foreach (['sites', 'functions'] as $resource) { - $this->listByGroup($resource, [ - Query::equal('installationInternalId', [$document->getSequence()]) - ], $dbForProject, function ($document) use ($resource, $dbForProject, $dbForPlatform) { - $dbForPlatform->deleteDocument('repositories', $document->getAttribute('repositoryId')); + foreach (['sites', 'functions'] as $collection) { + $this->listByGroup($collection, [ + Query::equal('installationInternalId', [$installation->getSequence()]) + ], $dbForProject, function ($document) use ($collection, $dbForProject, $dbForPlatform) { + $repositoryId = $document->getAttribute('repositoryId', ''); + if (!empty($repositoryId)) { + $dbForPlatform->deleteDocument('repositories', $repositoryId); + } $document = $document ->setAttribute('installationId', '') @@ -1416,7 +1419,7 @@ class Deletes extends Action ->setAttribute('providerRootDirectory', '') ->setAttribute('repositoryId', '') ->setAttribute('repositoryInternalId', ''); - $dbForProject->updateDocument($resource, $document->getId(), $document); + $dbForProject->updateDocument($collection, $document->getId(), $document); }); } } From f8f2f9ca255c639e94e47a93c383577582426500 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 5 Feb 2026 14:03:26 +0530 Subject: [PATCH 12/15] removed reduntant test --- .../Realtime/RealtimeCustomClientQueryTest.php | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 4f030386c8..37ea1e2e05 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -2262,22 +2262,4 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } - - public function testConsole() - { - $this->client->call(Client::METHOD_POST, '/databases/' . '6981e806000e18b050be' . '/collections/' . 'kv' . '/documents', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => '6981e7dc0003192f3424', - ], $this->getHeaders()), [ - 'documentId' => ID::unique(), - 'data' => [ - 'key' => 'key', - 'value' => 'value' - ], - 'permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - ]); - } } From 1c6880a78e1ff6864d1b19f0f2bd952b66ac6c81 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:48:37 +0530 Subject: [PATCH 13/15] Improve certificate sync logs (#11243) * Add friendly message for async certificate generation * better msg * simplify * remove escaping * set certificateId --- .../Platform/Workers/Certificates.php | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index bfa6bf87c7..4f898c7e98 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -290,9 +290,11 @@ class Certificates extends Action $certificate->setAttribute('domain', $domain->get()); } + $date = \date('H:i:s'); + $logs = "\033[90m[{$date}] \033[97mProcessing SSL certificate issuance. \033[0m\n"; + try { - $date = \date('H:i:s'); - $certificate->setAttribute('logs', "\033[90m[{$date}] \033[97mCertificate generation started. \033[0m\n"); + $certificate->setAttribute('logs', $logs); // Persist ASAP so that logs are reset in retry flow and user can see the latest logs on Console. $certificate = $this->upsertCertificate($rule, $certificate, $dbForPlatform); @@ -314,10 +316,16 @@ class Certificates extends Action $certName = ID::unique(); $renewDate = $certificates->issueCertificate($certName, $domain->get(), $domainType); + $date = \date('H:i:s'); // If certificate is generated instantly, we can mark the rule as 'verified'. if ($certificates->isInstantGeneration($domain->get(), $domainType)) { $rule->setAttribute('status', RULE_STATUS_VERIFIED); - $certificate->setAttribute('logs', 'Certificate successfully generated.'); + $logs .= "\033[90m[{$date}] \033[97mSSL certificate successfully issued. \033[0m\n"; + $certificate->setAttribute('logs', $logs); + } else { + // Delayed generation: third-party handles certificate issuance asynchronously + $logs .= "\033[90m[{$date}] \033[97mSSL certificate is being issued. This usually takes a few minutes — no action needed on your end. We'll periodically check and update the status. \033[0m\n"; + $certificate->setAttribute('logs', $logs); } $certificate->setAttributes([ @@ -326,16 +334,14 @@ class Certificates extends Action 'renewDate' => $renewDate, ]); } catch (Throwable $e) { - $logs = $e->getMessage(); - $currentLogs = $certificate->getAttribute('logs', ''); $date = \date('H:i:s'); - $errorMessage = "\033[90m[{$date}] \033[31mCertificate generation failed: \033[0m\n"; + $logs .= "\033[90m[{$date}] \033[31mSSL certificate issuance failed: \033[0m\n"; + $logs .= \mb_strcut($e->getMessage(), 0, 500000); // Limit to 500kb $attempts = $certificate->getAttribute('attempts', 0) + 1; // Increase attempts count // Update attributes on certificate document $certificate->setAttributes([ - 'logs' => $currentLogs . $errorMessage . \mb_strcut($logs, 0, 500000), // Limit to 500kb 'attempts' => $attempts, 'renewDate' => DateTime::now(), // Store current time as renew date to ensure another attempt in next maintenance cycle. ]); @@ -348,14 +354,13 @@ class Certificates extends Action throw $e; } finally { - // All actions result in new 'updated' date - $certificate->setAttribute('updated', DateTime::now()); - // Save certificate document to database + // Update certificate document with logs + $certificate->setAttribute('logs', $logs); $this->upsertCertificate($rule, $certificate, $dbForPlatform); - // Ensure certificate is associated with the rule - $rule->setAttribute('certificateId', $certificate->getId()); // Update rule and emit events + $rule->setAttribute('certificateId', $certificate->getId()); + $rule->setAttribute('logs', $logs); $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime); } } From cbfef909eed373ce611c48669dc8d0f5c9a96934 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 5 Feb 2026 15:03:27 +0530 Subject: [PATCH 14/15] reset permissions --- src/Appwrite/Platform/Modules/Compute/Base.php | 11 +---------- .../Modules/Projects/Http/Projects/Action.php | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index ee5f679330..db0cfe7daf 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -32,20 +32,11 @@ class Base extends Action protected function getPermissions(string $teamId, string $projectId): array { return [ - // Team-wide permissions - Permission::read(Role::team(ID::custom($teamId), 'owner')), - Permission::read(Role::team(ID::custom($teamId), 'developer')), + Permission::read(Role::team(ID::custom($teamId))), Permission::update(Role::team(ID::custom($teamId), 'owner')), Permission::update(Role::team(ID::custom($teamId), 'developer')), Permission::delete(Role::team(ID::custom($teamId), 'owner')), Permission::delete(Role::team(ID::custom($teamId), 'developer')), - // Project-wide permissions - Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), - Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), - Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), - Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), - Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), - Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), ]; } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php index 21cd108485..1a3be1e783 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php @@ -12,20 +12,11 @@ class Action extends AppwriteAction protected function getPermissions(string $teamId, string $projectId): array { return [ - // Team-wide permissions - Permission::read(Role::team(ID::custom($teamId), 'owner')), - Permission::read(Role::team(ID::custom($teamId), 'developer')), + Permission::read(Role::team(ID::custom($teamId))), Permission::update(Role::team(ID::custom($teamId), 'owner')), Permission::update(Role::team(ID::custom($teamId), 'developer')), Permission::delete(Role::team(ID::custom($teamId), 'owner')), Permission::delete(Role::team(ID::custom($teamId), 'developer')), - // Project-wide permissions - Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), - Permission::read(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), - Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), - Permission::update(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), - Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-owner")), - Permission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-developer")), ]; } } From 657f16031b708f1dbfb976d5d88a73b3729b747c Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 5 Feb 2026 15:11:17 +0530 Subject: [PATCH 15/15] lint --- app/controllers/api/projects.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 27f02fb2d0..d61340cfff 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -16,7 +16,6 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; -use Utopia\App; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document;