From 01cc9bcd1f2f8855a3d223fa9a72e7adf0c44686 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Thu, 29 Jan 2026 21:35:42 +0530 Subject: [PATCH] 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',