diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml new file mode 100644 index 0000000000..92b4f454cb --- /dev/null +++ b/.github/workflows/sdk-preview.yml @@ -0,0 +1,33 @@ +name: "Console SDK Preview" + +on: + pull_request: + paths: + - 'app/config/specs/*-latest-console.json' + + +jobs: + setup: + name: Setup & Build Console SDK + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Load and Start Appwrite + run: | + docker compose build + docker compose up -d + docker compose exec appwrite sdks --platform=console --sdk=web --version=latest --git=no + sudo chown -R $USER:$USER ./app/sdks/console-web + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build and Publish SDK + working-directory: ./app/sdks/console-web + run: | + npm install + npm run build + npx pkg-pr-new publish --comment=update diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29aa9b0581..3ab240c1b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -127,6 +127,11 @@ jobs: Messaging, Migrations ] + tables-mode: [ + 'Project', + 'Shared V1', + 'Shared V2', + ] steps: - name: checkout @@ -145,11 +150,26 @@ jobs: docker compose up -d sleep 30 - - name: Run ${{matrix.service}} Tests - run: docker compose exec -T appwrite test /usr/src/code/tests/e2e/Services/${{matrix.service}} --debug - - - name: Run ${{matrix.service}} Shared Tables Tests - run: _APP_DATABASE_SHARED_TABLES=database_db_main docker compose exec -T appwrite test /usr/src/code/tests/e2e/Services/${{matrix.service}} --debug + - name: Run ${{ matrix.service }} tests with ${{ matrix.tables-mode }} table mode + run: | + if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then + echo "Using shared tables V1" + export _APP_DATABASE_SHARED_TABLES=database_db_main + export _APP_DATABASE_SHARED_TABLES_V1=database_db_main + elif [ "${{ matrix.tables-mode }}" == "Shared V2" ]; then + echo "Using shared tables V2" + export _APP_DATABASE_SHARED_TABLES=database_db_main + export _APP_DATABASE_SHARED_TABLES_V1= + else + echo "Using project tables" + export _APP_DATABASE_SHARED_TABLES= + export _APP_DATABASE_SHARED_TABLES_V1= + fi + + docker compose exec -T \ + -e _APP_DATABASE_SHARED_TABLES \ + -e _APP_DATABASE_SHARED_TABLES_V1 \ + appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug benchmarking: name: Benchmark diff --git a/.gitignore b/.gitignore index 0d2b4b51ff..a68af84071 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ dev/yasd_init.php .phpunit.result.cache Makefile appwrite.json -.zed/ \ No newline at end of file +/.zed/ diff --git a/app/cli.php b/app/cli.php index 23502ec402..b0f053d3c6 100644 --- a/app/cli.php +++ b/app/cli.php @@ -114,8 +114,9 @@ CLI::setResource('getProjectDB', function (Group $pools, Database $dbForConsole, if (isset($databases[$dsn->getHost()])) { $database = $databases[$dsn->getHost()]; + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -136,10 +137,10 @@ CLI::setResource('getProjectDB', function (Group $pools, Database $dbForConsole, ->getResource(); $database = new Database($dbAdapter, $cache); - $databases[$dsn->getHost()] = $database; + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/app/config/errors.php b/app/config/errors.php index 3afec4faaf..f09d1596eb 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -686,7 +686,7 @@ return [ ], Exception::ATTRIBUTE_LIMIT_EXCEEDED => [ 'name' => Exception::ATTRIBUTE_LIMIT_EXCEEDED, - 'description' => 'The maximum number of attributes has been reached.', + 'description' => 'The maximum number or size of attributes for this collection has been reached.', 'code' => 400, ], Exception::ATTRIBUTE_VALUE_INVALID => [ diff --git a/app/config/specs/open-api3-latest-client.json b/app/config/specs/open-api3-latest-client.json index e3cd909b4e..d948a101f2 100644 --- a/app/config/specs/open-api3-latest-client.json +++ b/app/config/specs/open-api3-latest-client.json @@ -2784,7 +2784,7 @@ "tags": [ "account" ], - "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", + "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", "responses": { "201": { "description": "Token", diff --git a/app/config/specs/open-api3-latest-console.json b/app/config/specs/open-api3-latest-console.json index 4e97fecc30..fb01509ecd 100644 --- a/app/config/specs/open-api3-latest-console.json +++ b/app/config/specs/open-api3-latest-console.json @@ -2795,7 +2795,7 @@ "tags": [ "account" ], - "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", + "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", "responses": { "201": { "description": "Token", @@ -20672,7 +20672,7 @@ }, "\/projects\/{projectId}\/auth\/memberships-privacy": { "patch": { - "summary": "Update project team sensitive attributes", + "summary": "Update project memberships privacy attributes", "operationId": "projectsUpdateMembershipsPrivacy", "tags": [ "projects" @@ -36077,17 +36077,17 @@ "description": "Whether or not to send session alert emails to users.", "x-example": true }, - "membershipsUserName": { + "authMembershipsUserName": { "type": "boolean", "description": "Whether or not to show user names in the teams membership response.", "x-example": true }, - "membershipsUserEmail": { + "authMembershipsUserEmail": { "type": "boolean", "description": "Whether or not to show user emails in the teams membership response.", "x-example": true }, - "membershipsMfa": { + "authMembershipsMfa": { "type": "boolean", "description": "Whether or not to show user MFA status in the teams membership response.", "x-example": true @@ -36297,9 +36297,9 @@ "authPersonalDataCheck", "authMockNumbers", "authSessionAlerts", - "membershipsUserName", - "membershipsUserEmail", - "membershipsMfa", + "authMembershipsUserName", + "authMembershipsUserEmail", + "authMembershipsMfa", "oAuthProviders", "platforms", "webhooks", diff --git a/app/config/specs/open-api3-latest-server.json b/app/config/specs/open-api3-latest-server.json index e84b751743..1504322456 100644 --- a/app/config/specs/open-api3-latest-server.json +++ b/app/config/specs/open-api3-latest-server.json @@ -2451,7 +2451,7 @@ "tags": [ "account" ], - "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", + "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", "responses": { "201": { "description": "Token", diff --git a/app/config/specs/swagger2-latest-client.json b/app/config/specs/swagger2-latest-client.json index b1b9ce8dca..3ac70ffe28 100644 --- a/app/config/specs/swagger2-latest-client.json +++ b/app/config/specs/swagger2-latest-client.json @@ -2922,7 +2922,7 @@ "tags": [ "account" ], - "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", + "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", "responses": { "201": { "description": "Token", diff --git a/app/config/specs/swagger2-latest-console.json b/app/config/specs/swagger2-latest-console.json index c7b6bac2d4..25c0c289b3 100644 --- a/app/config/specs/swagger2-latest-console.json +++ b/app/config/specs/swagger2-latest-console.json @@ -2949,7 +2949,7 @@ "tags": [ "account" ], - "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", + "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", "responses": { "201": { "description": "Token", @@ -21138,7 +21138,7 @@ }, "\/projects\/{projectId}\/auth\/memberships-privacy": { "patch": { - "summary": "Update project team sensitive attributes", + "summary": "Update project memberships privacy attributes", "operationId": "projectsUpdateMembershipsPrivacy", "consumes": [ "application\/json" @@ -36591,17 +36591,17 @@ "description": "Whether or not to send session alert emails to users.", "x-example": true }, - "membershipsUserName": { + "authMembershipsUserName": { "type": "boolean", "description": "Whether or not to show user names in the teams membership response.", "x-example": true }, - "membershipsUserEmail": { + "authMembershipsUserEmail": { "type": "boolean", "description": "Whether or not to show user emails in the teams membership response.", "x-example": true }, - "membershipsMfa": { + "authMembershipsMfa": { "type": "boolean", "description": "Whether or not to show user MFA status in the teams membership response.", "x-example": true @@ -36815,9 +36815,9 @@ "authPersonalDataCheck", "authMockNumbers", "authSessionAlerts", - "membershipsUserName", - "membershipsUserEmail", - "membershipsMfa", + "authMembershipsUserName", + "authMembershipsUserEmail", + "authMembershipsMfa", "oAuthProviders", "platforms", "webhooks", diff --git a/app/config/specs/swagger2-latest-server.json b/app/config/specs/swagger2-latest-server.json index 2c8e80c65e..68bcf268fb 100644 --- a/app/config/specs/swagger2-latest-server.json +++ b/app/config/specs/swagger2-latest-server.json @@ -2604,7 +2604,7 @@ "tags": [ "account" ], - "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour. If you are on a mobile device you can leave the URL parameter empty, so that the login completion will be handled by your Appwrite instance by default.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", + "description": "Sends the user an email with a secret key for creating a session. If the provided user ID has not been registered, a new user will be created. When the user clicks the link in the email, the user is redirected back to the URL you provided with the secret key and userId values attached to the URL query string. Use the query string parameters to submit a request to the [POST \/v1\/account\/sessions\/token](https:\/\/appwrite.io\/docs\/references\/cloud\/client-web\/account#createSession) endpoint to complete the login process. The link sent to the user's email address is valid for 1 hour.\n\nA user is limited to 10 active sessions at a time by default. [Learn more about session limits](https:\/\/appwrite.io\/docs\/authentication-security#limits).\n", "responses": { "201": { "description": "Token", diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 4dfaf0cd5c..a00324f3b7 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -152,7 +152,7 @@ function createAttribute(string $databaseId, string $collectionId, Document $att } catch (DuplicateException) { throw new Exception(Exception::ATTRIBUTE_ALREADY_EXISTS); } catch (LimitException) { - throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED, 'Attribute limit exceeded'); + throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED); } catch (\Throwable $e) { $dbForProject->purgeCachedDocument('database_' . $db->getInternalId(), $collectionId); $dbForProject->purgeCachedCollection('database_' . $db->getInternalId() . '_collection_' . $collection->getInternalId()); @@ -196,7 +196,7 @@ function createAttribute(string $databaseId, string $collectionId, Document $att throw new Exception(Exception::ATTRIBUTE_ALREADY_EXISTS); } catch (LimitException) { $dbForProject->deleteDocument('attributes', $attribute->getId()); - throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED, 'Attribute limit exceeded'); + throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED); } catch (\Throwable $e) { $dbForProject->purgeCachedDocument('database_' . $db->getInternalId(), $relatedCollection->getId()); $dbForProject->purgeCachedCollection('database_' . $db->getInternalId() . '_collection_' . $relatedCollection->getInternalId()); @@ -392,6 +392,8 @@ function updateAttribute( throw new Exception(Exception::ATTRIBUTE_INVALID_RESIZE); } catch (NotFoundException) { throw new Exception(Exception::ATTRIBUTE_NOT_FOUND); + } catch (LimitException) { + throw new Exception(Exception::ATTRIBUTE_LIMIT_EXCEEDED); } } @@ -2631,7 +2633,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/indexes') $validator = new IndexValidator( $collection->getAttribute('attributes'), - $dbForProject->getAdapter()->getMaxIndexLength() + $dbForProject->getAdapter()->getMaxIndexLength(), + $dbForProject->getAdapter()->getInternalIndexesKeys(), ); if (!$validator->isValid($index)) { throw new Exception(Exception::INDEX_INVALID, $validator->getDescription()); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index df8b1cb07b..92d3103293 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -122,6 +122,10 @@ App::post('/v1/projects') $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', []); $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); @@ -132,16 +136,14 @@ App::post('/v1/projects') $dsn = $databases[array_rand($databases)]; } - if ($projectId === 'console') { - throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); - } - // TODO: Temporary until all projects are using shared tables. - if ($dsn === System::getEnv('_APP_DATABASE_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 . '://' . System::getEnv('_APP_DATABASE_SHARED_TABLES', '') . '?database=' . $database; + $dsn = $schema . '://' . $dsn . '?database=' . $database; if (!empty($namespace)) { $dsn .= '&namespace=' . $namespace; @@ -195,47 +197,92 @@ App::post('/v1/projects') $adapter = $pools->get($dsn->getHost())->pop()->getResource(); $dbForProject = new Database($adapter, $cache); + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { - $dbForProject - ->setSharedTables(true) - ->setTenant($project->getInternalId()) - ->setNamespace($dsn->getParam('namespace')); - } else { - $dbForProject - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getInternalId()); - } + $projectTables = !\in_array($dsn->getHost(), $sharedTables); + $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); + $sharedTablesV2 = !$projectTables && !$sharedTablesV1; + $sharedTables = $sharedTablesV1 || $sharedTablesV2; - $dbForProject->create(); - - $audit = new Audit($dbForProject); - $audit->setup(); - - $abuse = new TimeLimit('', 0, 1, $dbForProject); - $abuse->setup(); - - /** @var array $collections */ - $collections = Config::getParam('collections', [])['projects'] ?? []; - - foreach ($collections as $key => $collection) { - if (($collection['$collection'] ?? '') !== Database::METADATA) { - continue; + if (!$sharedTablesV2) { + if ($sharedTables) { + $dbForProject + ->setSharedTables(true) + ->setTenant($sharedTablesV1 ? $project->getInternalId() : null) + ->setNamespace($dsn->getParam('namespace')); + } else { + $dbForProject + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getInternalId()); } - $attributes = \array_map(function (array $attribute) { - return new Document($attribute); - }, $collection['attributes']); - - $indexes = \array_map(function (array $index) { - return new Document($index); - }, $collection['indexes']); + $create = true; try { - $dbForProject->createCollection($key, $attributes, $indexes); + $dbForProject->create(); } catch (Duplicate) { - // Collection already exists + $create = false; + } + + if ($create || $projectTables) { + $audit = new Audit($dbForProject); + $audit->setup(); + + $abuse = new TimeLimit('', 0, 1, $dbForProject); + $abuse->setup(); + } + + if (!$create && $sharedTablesV1) { + $attributes = \array_map(fn ($attribute) => new Document($attribute), Audit::ATTRIBUTES); + $indexes = \array_map(fn (array $index) => new Document($index), Audit::INDEXES); + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom('audit'), + '$permissions' => [Permission::create(Role::any())], + 'name' => 'audit', + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + + $attributes = \array_map(fn ($attribute) => new Document($attribute), TimeLimit::ATTRIBUTES); + $indexes = \array_map(fn (array $index) => new Document($index), TimeLimit::INDEXES); + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom('abuse'), + '$permissions' => [Permission::create(Role::any())], + 'name' => 'abuse', + '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 + ])); + } + } } } diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index dda39a0db0..911481a1c0 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -68,19 +68,19 @@ App::post('/v1/storage/buckets') ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) ->param('maximumFileSize', fn (array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn (array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) - ->param('compression', Compression::NONE, new WhiteList([Compression::NONE, Compression::GZIP, Compression::ZSTD]), 'Compression algorithm choosen for compression. Can be one of ' . Compression::NONE . ', [' . Compression::GZIP . '](https://en.wikipedia.org/wiki/Gzip), or [' . Compression::ZSTD . '](https://en.wikipedia.org/wiki/Zstd), For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' compression is skipped even if it\'s enabled', true) + ->param('compression', Compression::NONE, new WhiteList([Compression::NONE, Compression::GZIP, Compression::ZSTD], true), 'Compression algorithm choosen for compression. Can be one of ' . Compression::NONE . ', [' . Compression::GZIP . '](https://en.wikipedia.org/wiki/Gzip), or [' . Compression::ZSTD . '](https://en.wikipedia.org/wiki/Zstd), For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' compression is skipped even if it\'s enabled', true) ->param('encryption', true, new Boolean(true), 'Is encryption enabled? For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' encryption is skipped even if it\'s enabled', true) ->param('antivirus', true, new Boolean(true), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS, 0) . ' AntiVirus scanning is skipped even if it\'s enabled', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, int $maximumFileSize, array $allowedFileExtensions, string $compression, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $queueForEvents) { + ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, int $maximumFileSize, array $allowedFileExtensions, ?string $compression, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $queueForEvents) { $bucketId = $bucketId === 'unique()' ? ID::unique() : $bucketId; // Map aggregate permissions into the multiple permissions they represent. $permissions = Permission::aggregate($permissions); - + $compression ??= Compression::NONE; try { $files = (Config::getParam('collections', [])['buckets'] ?? [])['files'] ?? []; if (empty($files)) { @@ -254,13 +254,13 @@ App::put('/v1/storage/buckets/:bucketId') ->param('enabled', true, new Boolean(true), 'Is bucket enabled? When set to \'disabled\', users cannot access the files in this bucket but Server SDKs with and API key can still access the bucket. No files are lost when this is toggled.', true) ->param('maximumFileSize', fn (array $plan) => empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000, fn (array $plan) => new Range(1, empty($plan['fileSize']) ? (int) System::getEnv('_APP_STORAGE_LIMIT', 0) : $plan['fileSize'] * 1000 * 1000), 'Maximum file size allowed in bytes. Maximum allowed value is ' . Storage::human(System::getEnv('_APP_STORAGE_LIMIT', 0), 0) . '.', true, ['plan']) ->param('allowedFileExtensions', [], new ArrayList(new Text(64), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Allowed file extensions. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' extensions are allowed, each 64 characters long.', true) - ->param('compression', Compression::NONE, new WhiteList([Compression::NONE, Compression::GZIP, Compression::ZSTD]), 'Compression algorithm choosen for compression. Can be one of ' . Compression::NONE . ', [' . Compression::GZIP . '](https://en.wikipedia.org/wiki/Gzip), or [' . Compression::ZSTD . '](https://en.wikipedia.org/wiki/Zstd), For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' compression is skipped even if it\'s enabled', true) + ->param('compression', Compression::NONE, new WhiteList([Compression::NONE, Compression::GZIP, Compression::ZSTD], true), 'Compression algorithm choosen for compression. Can be one of ' . Compression::NONE . ', [' . Compression::GZIP . '](https://en.wikipedia.org/wiki/Gzip), or [' . Compression::ZSTD . '](https://en.wikipedia.org/wiki/Zstd), For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' compression is skipped even if it\'s enabled', true) ->param('encryption', true, new Boolean(true), 'Is encryption enabled? For file size above ' . Storage::human(APP_STORAGE_READ_BUFFER, 0) . ' encryption is skipped even if it\'s enabled', true) ->param('antivirus', true, new Boolean(true), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS, 0) . ' AntiVirus scanning is skipped even if it\'s enabled', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, ?int $maximumFileSize, array $allowedFileExtensions, string $compression, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $queueForEvents) { + ->action(function (string $bucketId, string $name, ?array $permissions, bool $fileSecurity, bool $enabled, ?int $maximumFileSize, array $allowedFileExtensions, ?string $compression, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $queueForEvents) { $bucket = $dbForProject->getDocument('buckets', $bucketId); if ($bucket->isEmpty()) { @@ -273,6 +273,7 @@ App::put('/v1/storage/buckets/:bucketId') $enabled ??= $bucket->getAttribute('enabled', true); $encryption ??= $bucket->getAttribute('encryption', true); $antivirus ??= $bucket->getAttribute('antivirus', true); + $compression ??= $bucket->getAttribute('compression', Compression::NONE); // Map aggregate permissions into the multiple permissions they represent. $permissions = Permission::aggregate($permissions); diff --git a/app/controllers/general.php b/app/controllers/general.php index 663242882a..564a5b7df4 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -63,7 +63,7 @@ function router(App $utopia, Database $dbForConsole, callable $getProjectDB, Swo } if (System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'disabled') === 'enabled') { - if ($host !== 'localhost' && $host !== APP_HOSTNAME_INTERNAL) { // localhost allowed for proxy, APP_HOSTNAME_INTERNAL allowed for migrations + if ($host !== 'localhost' && $host !== APP_HOSTNAME_INTERNAL && $host !== System::getEnv('_APP_CONSOLE_DOMAIN', '')) { throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.'); } } diff --git a/app/http.php b/app/http.php index 641143694d..4bfacd4cac 100644 --- a/app/http.php +++ b/app/http.php @@ -12,10 +12,12 @@ use Swoole\Process; use Utopia\Abuse\Adapters\Database\TimeLimit; use Utopia\App; use Utopia\Audit\Audit; +use Utopia\Cache\Cache; use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -60,8 +62,6 @@ include __DIR__ . '/controllers/general.php'; $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $register) { $app = new App('UTC'); - $app->setCompression(true); - $app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB go(function () use ($register, $app) { $pools = $register->get('pools'); @@ -91,9 +91,9 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg Console::success('[Setup] - Server database init started...'); try { - Console::success('[Setup] - Creating database: appwrite...'); + Console::success('[Setup] - Creating console database...'); $dbForConsole->create(); - } catch (\Throwable $e) { + } catch (Duplicate) { Console::success('[Setup] - Skip: metadata table already exists'); } @@ -118,34 +118,10 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg continue; } - Console::success('[Setup] - Creating collection: ' . $collection['$id'] . '...'); + Console::success('[Setup] - Creating console collection: ' . $collection['$id'] . '...'); - $attributes = []; - $indexes = []; - - foreach ($collection['attributes'] as $attribute) { - $attributes[] = new Document([ - '$id' => ID::custom($attribute['$id']), - 'type' => $attribute['type'], - 'size' => $attribute['size'], - 'required' => $attribute['required'], - 'signed' => $attribute['signed'], - 'array' => $attribute['array'], - 'filters' => $attribute['filters'], - 'default' => $attribute['default'] ?? null, - 'format' => $attribute['format'] ?? '' - ]); - } - - foreach ($collection['indexes'] as $index) { - $indexes[] = new Document([ - '$id' => ID::custom($index['$id']), - 'type' => $index['type'], - 'attributes' => $index['attributes'], - 'lengths' => $index['lengths'], - 'orders' => $index['orders'], - ]); - } + $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); $dbForConsole->createCollection($key, $attributes, $indexes); } @@ -180,36 +156,55 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg throw new Exception('Files collection is not configured.'); } - $attributes = []; - $indexes = []; - - foreach ($files['attributes'] as $attribute) { - $attributes[] = new Document([ - '$id' => ID::custom($attribute['$id']), - 'type' => $attribute['type'], - 'size' => $attribute['size'], - 'required' => $attribute['required'], - 'signed' => $attribute['signed'], - 'array' => $attribute['array'], - 'filters' => $attribute['filters'], - 'default' => $attribute['default'] ?? null, - 'format' => $attribute['format'] ?? '' - ]); - } - - foreach ($files['indexes'] as $index) { - $indexes[] = new Document([ - '$id' => ID::custom($index['$id']), - 'type' => $index['type'], - 'attributes' => $index['attributes'], - 'lengths' => $index['lengths'], - 'orders' => $index['orders'], - ]); - } + $attributes = \array_map(fn ($attribute) => new Document($attribute), $files['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $files['indexes']); $dbForConsole->createCollection('bucket_' . $bucket->getInternalId(), $attributes, $indexes); } + $projectCollections = $collections['projects']; + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); + $sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1); + + $cache = $app->getResource('cache'); + + foreach ($sharedTablesV2 as $hostname) { + $adapter = $pools + ->get($hostname) + ->pop() + ->getResource(); + + $dbForProject = (new Database($adapter, $cache)) + ->setDatabase('appwrite') + ->setSharedTables(true) + ->setTenant(null) + ->setNamespace(System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', '')); + + try { + Console::success('[Setup] - Creating project database: ' . $hostname . '...'); + $dbForProject->create(); + } catch (Duplicate) { + Console::success('[Setup] - Skip: metadata table already exists'); + } + + foreach ($projectCollections as $key => $collection) { + if (($collection['$collection'] ?? '') !== Database::METADATA) { + continue; + } + if (!$dbForProject->getCollection($key)->isEmpty()) { + continue; + } + + $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); + + Console::success('[Setup] - Creating project collection: ' . $collection['$id'] . '...'); + + $dbForProject->createCollection($key, $attributes, $indexes); + } + } + $pools->reclaim(); Console::success('[Setup] - Server database init completed...'); @@ -225,7 +220,7 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $reg }); }); -$http->on('request', function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) use ($register) { +$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) use ($register) { App::setResource('swooleRequest', fn () => $swooleRequest); App::setResource('swooleResponse', fn () => $swooleResponse); diff --git a/app/init.php b/app/init.php index c9ec2e0061..047e9402b6 100644 --- a/app/init.php +++ b/app/init.php @@ -1425,14 +1425,9 @@ App::setResource('dbForProject', function (Group $pools, Database $dbForConsole, ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); - try { - $dsn = new DSN($project->getAttribute('database')); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $project->getAttribute('database')); - } + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -1487,7 +1482,9 @@ App::setResource('getProjectDB', function (Group $pools, Database $dbForConsole, ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/app/realtime.php b/app/realtime.php index 15635058cf..cfb9bf9a16 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -93,7 +93,9 @@ if (!function_exists('getProjectDB')) { $database = new Database($adapter, getCache()); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/app/worker.php b/app/worker.php index 6b7aba5c1b..7899c1d5bc 100644 --- a/app/worker.php +++ b/app/worker.php @@ -93,7 +93,9 @@ Server::setResource('dbForProject', function (Cache $cache, Registry $register, $dsn = new DSN('mysql://' . $project->getAttribute('database')); } - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -126,7 +128,9 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForConso if (isset($databases[$dsn->getHost()])) { $database = $databases[$dsn->getHost()]; - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -150,7 +154,9 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForConso $databases[$dsn->getHost()] = $database; - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/composer.json b/composer.json index 99f696e116..e3fbcabc83 100644 --- a/composer.json +++ b/composer.json @@ -45,13 +45,13 @@ "ext-sockets": "*", "appwrite/php-runtimes": "0.16.*", "appwrite/php-clamav": "2.0.*", - "utopia-php/abuse": "0.43.0", + "utopia-php/abuse": "0.43.*", "utopia-php/analytics": "0.10.*", - "utopia-php/audit": "0.43.0", + "utopia-php/audit": "0.43.*", "utopia-php/cache": "0.11.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "0.53.16", + "utopia-php/database": "0.53.20", "utopia-php/domains": "0.5.*", "utopia-php/dsn": "0.2.1", "utopia-php/framework": "0.33.*", diff --git a/composer.lock b/composer.lock index 508de82034..87d8b47583 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c56db8736d679aff6e2b275ec59f1dbf", + "content-hash": "ae3b9a491c9870a4897cdf712caba1e3", "packages": [ { "name": "adhocore/jwt", @@ -753,28 +753,28 @@ }, { "name": "jean85/pretty-package-versions", - "version": "2.0.6", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/Jean85/pretty-package-versions.git", - "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4" + "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/f9fdd29ad8e6d024f52678b570e5593759b550b4", - "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/3c4e5f62ba8d7de1734312e4fff32f67a8daaf10", + "reference": "3c4e5f62ba8d7de1734312e4fff32f67a8daaf10", "shasum": "" }, "require": { - "composer-runtime-api": "^2.0.0", - "php": "^7.1|^8.0" + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "jean85/composer-provided-replaced-stub-package": "^1.0", "phpstan/phpstan": "^1.4", - "phpunit/phpunit": "^7.5|^8.5|^9.4", - "vimeo/psalm": "^4.3" + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "vimeo/psalm": "^4.3 || ^5.0" }, "type": "library", "extra": { @@ -806,9 +806,9 @@ ], "support": { "issues": "https://github.com/Jean85/pretty-package-versions/issues", - "source": "https://github.com/Jean85/pretty-package-versions/tree/2.0.6" + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.0" }, - "time": "2024-03-08T09:58:59+00:00" + "time": "2024-11-18T16:19:46+00:00" }, { "name": "league/csv", @@ -2453,16 +2453,16 @@ }, { "name": "symfony/http-client", - "version": "v7.1.7", + "version": "v7.1.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "90ab2a4992dcf5d1f19a9b8737eba36a7c305fd0" + "reference": "c30d91a1deac0dc3ed5e604683cf2e1dfc635b8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/90ab2a4992dcf5d1f19a9b8737eba36a7c305fd0", - "reference": "90ab2a4992dcf5d1f19a9b8737eba36a7c305fd0", + "url": "https://api.github.com/repos/symfony/http-client/zipball/c30d91a1deac0dc3ed5e604683cf2e1dfc635b8a", + "reference": "c30d91a1deac0dc3ed5e604683cf2e1dfc635b8a", "shasum": "" }, "require": { @@ -2527,7 +2527,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.1.7" + "source": "https://github.com/symfony/http-client/tree/v7.1.8" }, "funding": [ { @@ -2543,7 +2543,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T16:45:54+00:00" + "time": "2024-11-13T13:40:27+00:00" }, { "name": "symfony/http-client-contracts", @@ -3135,16 +3135,16 @@ }, { "name": "utopia-php/abuse", - "version": "0.43.0", + "version": "0.43.1", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "6346a3b4c5177a43160035a7289e30fdfb0790d6" + "reference": "e404c21e8dcf6a310bc83cf1d74e716b105598fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/6346a3b4c5177a43160035a7289e30fdfb0790d6", - "reference": "6346a3b4c5177a43160035a7289e30fdfb0790d6", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/e404c21e8dcf6a310bc83cf1d74e716b105598fa", + "reference": "e404c21e8dcf6a310bc83cf1d74e716b105598fa", "shasum": "" }, "require": { @@ -3180,9 +3180,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/0.43.0" + "source": "https://github.com/utopia-php/abuse/tree/0.43.1" }, - "time": "2024-08-30T05:17:23+00:00" + "time": "2024-10-23T04:29:12+00:00" }, { "name": "utopia-php/analytics", @@ -3232,16 +3232,16 @@ }, { "name": "utopia-php/audit", - "version": "0.43.0", + "version": "0.43.1", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "cef22b5dc6a6d28fcd522f41c7bf7ded4a4dfd3e" + "reference": "04a47dd1f5f92e2d50e971a06bcc9e759325d277" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/cef22b5dc6a6d28fcd522f41c7bf7ded4a4dfd3e", - "reference": "cef22b5dc6a6d28fcd522f41c7bf7ded4a4dfd3e", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/04a47dd1f5f92e2d50e971a06bcc9e759325d277", + "reference": "04a47dd1f5f92e2d50e971a06bcc9e759325d277", "shasum": "" }, "require": { @@ -3273,9 +3273,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/0.43.0" + "source": "https://github.com/utopia-php/audit/tree/0.43.1" }, - "time": "2024-08-30T05:17:36+00:00" + "time": "2024-10-23T04:27:59+00:00" }, { "name": "utopia-php/cache", @@ -3475,16 +3475,16 @@ }, { "name": "utopia-php/database", - "version": "0.53.16", + "version": "0.53.20", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "6661edffeef05b59e16d102b989a72f7f78cf7de" + "reference": "e43f8ee26e06ee8812737e63642dbd7ee7c9dc04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/6661edffeef05b59e16d102b989a72f7f78cf7de", - "reference": "6661edffeef05b59e16d102b989a72f7f78cf7de", + "url": "https://api.github.com/repos/utopia-php/database/zipball/e43f8ee26e06ee8812737e63642dbd7ee7c9dc04", + "reference": "e43f8ee26e06ee8812737e63642dbd7ee7c9dc04", "shasum": "" }, "require": { @@ -3525,9 +3525,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.53.16" + "source": "https://github.com/utopia-php/database/tree/0.53.20" }, - "time": "2024-11-06T03:07:16+00:00" + "time": "2024-11-12T00:23:36+00:00" }, { "name": "utopia-php/domains", @@ -3677,16 +3677,16 @@ }, { "name": "utopia-php/framework", - "version": "0.33.12", + "version": "0.33.14", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "bfb7812df9e489b3cba7d5504a49ce578c71af1f" + "reference": "45a5a2db3602fa054096f378482c7da9936f5850" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/bfb7812df9e489b3cba7d5504a49ce578c71af1f", - "reference": "bfb7812df9e489b3cba7d5504a49ce578c71af1f", + "url": "https://api.github.com/repos/utopia-php/http/zipball/45a5a2db3602fa054096f378482c7da9936f5850", + "reference": "45a5a2db3602fa054096f378482c7da9936f5850", "shasum": "" }, "require": { @@ -3718,9 +3718,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.12" + "source": "https://github.com/utopia-php/http/tree/0.33.14" }, - "time": "2024-11-13T12:45:45+00:00" + "time": "2024-11-20T12:39:10+00:00" }, { "name": "utopia-php/image", @@ -4806,16 +4806,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "0.39.24", + "version": "0.39.25", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "412451c87f6ef17e24e9a5cf41721043d74c60c8" + "reference": "5b5323636a8d75a1c4faaae9728098dd6a6a47d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/412451c87f6ef17e24e9a5cf41721043d74c60c8", - "reference": "412451c87f6ef17e24e9a5cf41721043d74c60c8", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/5b5323636a8d75a1c4faaae9728098dd6a6a47d1", + "reference": "5b5323636a8d75a1c4faaae9728098dd6a6a47d1", "shasum": "" }, "require": { @@ -4823,7 +4823,7 @@ "ext-json": "*", "ext-mbstring": "*", "matthiasmullie/minify": "1.3.*", - "php": ">=8.0", + "php": ">=8.3", "twig/twig": "3.14.*" }, "require-dev": { @@ -4851,9 +4851,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/0.39.24" + "source": "https://github.com/appwrite/sdk-generator/tree/0.39.25" }, - "time": "2024-10-09T19:13:27+00:00" + "time": "2024-11-08T10:16:34+00:00" }, { "name": "doctrine/annotations", @@ -5127,16 +5127,16 @@ }, { "name": "laravel/pint", - "version": "v1.18.1", + "version": "v1.18.2", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "35c00c05ec43e6b46d295efc0f4386ceb30d50d9" + "reference": "f55daaf7eb6c2f49ddf6702fb42e3091c64d8a64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/35c00c05ec43e6b46d295efc0f4386ceb30d50d9", - "reference": "35c00c05ec43e6b46d295efc0f4386ceb30d50d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/f55daaf7eb6c2f49ddf6702fb42e3091c64d8a64", + "reference": "f55daaf7eb6c2f49ddf6702fb42e3091c64d8a64", "shasum": "" }, "require": { @@ -5189,7 +5189,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-09-24T17:22:50+00:00" + "time": "2024-11-20T09:33:46+00:00" }, { "name": "matthiasmullie/minify", @@ -5930,26 +5930,27 @@ }, { "name": "phpspec/prophecy", - "version": "v1.19.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "67a759e7d8746d501c41536ba40cd9c0a07d6a87" + "reference": "a0165c648cab6a80311c74ffc708a07bb53ecc93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/67a759e7d8746d501c41536ba40cd9c0a07d6a87", - "reference": "67a759e7d8746d501c41536ba40cd9c0a07d6a87", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/a0165c648cab6a80311c74ffc708a07bb53ecc93", + "reference": "a0165c648cab6a80311c74ffc708a07bb53ecc93", "shasum": "" }, "require": { "doctrine/instantiator": "^1.2 || ^2.0", - "php": "^7.2 || 8.0.* || 8.1.* || 8.2.* || 8.3.*", + "php": "^7.2 || 8.0.* || 8.1.* || 8.2.* || 8.3.* || 8.4.*", "phpdocumentor/reflection-docblock": "^5.2", "sebastian/comparator": "^3.0 || ^4.0 || ^5.0 || ^6.0", "sebastian/recursion-context": "^3.0 || ^4.0 || ^5.0 || ^6.0" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3.40", "phpspec/phpspec": "^6.0 || ^7.0", "phpstan/phpstan": "^1.9", "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0" @@ -5993,9 +5994,9 @@ ], "support": { "issues": "https://github.com/phpspec/prophecy/issues", - "source": "https://github.com/phpspec/prophecy/tree/v1.19.0" + "source": "https://github.com/phpspec/prophecy/tree/v1.20.0" }, - "time": "2024-02-29T11:52:51+00:00" + "time": "2024-11-19T13:12:41+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -7576,16 +7577,16 @@ }, { "name": "symfony/console", - "version": "v7.1.7", + "version": "v7.1.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "3284aafcac338b6e86fd955ee4d794cbe434151a" + "reference": "ff04e5b5ba043d2badfb308197b9e6b42883fcd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3284aafcac338b6e86fd955ee4d794cbe434151a", - "reference": "3284aafcac338b6e86fd955ee4d794cbe434151a", + "url": "https://api.github.com/repos/symfony/console/zipball/ff04e5b5ba043d2badfb308197b9e6b42883fcd5", + "reference": "ff04e5b5ba043d2badfb308197b9e6b42883fcd5", "shasum": "" }, "require": { @@ -7649,7 +7650,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.1.7" + "source": "https://github.com/symfony/console/tree/v7.1.8" }, "funding": [ { @@ -7665,7 +7666,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T15:34:55+00:00" + "time": "2024-11-06T14:23:19+00:00" }, { "name": "symfony/filesystem", @@ -8180,16 +8181,16 @@ }, { "name": "symfony/process", - "version": "v7.1.7", + "version": "v7.1.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "9b8a40b7289767aa7117e957573c2a535efe6585" + "reference": "42783370fda6e538771f7c7a36e9fa2ee3a84892" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/9b8a40b7289767aa7117e957573c2a535efe6585", - "reference": "9b8a40b7289767aa7117e957573c2a535efe6585", + "url": "https://api.github.com/repos/symfony/process/zipball/42783370fda6e538771f7c7a36e9fa2ee3a84892", + "reference": "42783370fda6e538771f7c7a36e9fa2ee3a84892", "shasum": "" }, "require": { @@ -8221,7 +8222,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.1.7" + "source": "https://github.com/symfony/process/tree/v7.1.8" }, "funding": [ { @@ -8237,20 +8238,20 @@ "type": "tidelift" } ], - "time": "2024-11-06T09:25:12+00:00" + "time": "2024-11-06T14:23:19+00:00" }, { "name": "symfony/string", - "version": "v7.1.6", + "version": "v7.1.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "61b72d66bf96c360a727ae6232df5ac83c71f626" + "reference": "591ebd41565f356fcd8b090fe64dbb5878f50281" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/61b72d66bf96c360a727ae6232df5ac83c71f626", - "reference": "61b72d66bf96c360a727ae6232df5ac83c71f626", + "url": "https://api.github.com/repos/symfony/string/zipball/591ebd41565f356fcd8b090fe64dbb5878f50281", + "reference": "591ebd41565f356fcd8b090fe64dbb5878f50281", "shasum": "" }, "require": { @@ -8308,7 +8309,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.6" + "source": "https://github.com/symfony/string/tree/v7.1.8" }, "funding": [ { @@ -8324,7 +8325,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-11-13T13:31:21+00:00" }, { "name": "textalk/websocket", @@ -8556,7 +8557,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/docker-compose.yml b/docker-compose.yml index 048178e60a..bae2cc7811 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -193,6 +193,8 @@ services: - _APP_EXPERIMENT_LOGGING_PROVIDER - _APP_EXPERIMENT_LOGGING_CONFIG - _APP_DATABASE_SHARED_TABLES + - _APP_DATABASE_SHARED_TABLES_V1 + - _APP_DATABASE_SHARED_NAMESPACE appwrite-console: <<: *x-logging @@ -382,6 +384,7 @@ services: - _APP_EXECUTOR_SECRET - _APP_EXECUTOR_HOST - _APP_DATABASE_SHARED_TABLES + - _APP_DATABASE_SHARED_TABLES_V1 appwrite-worker-databases: entrypoint: worker-databases diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 65d2f7717c..6e37f270a4 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -25,6 +25,9 @@ use Appwrite\Spec\Swagger2; use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Platform\Action; +use Utopia\Validator\Nullable; +use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class SDKs extends Action { @@ -37,23 +40,35 @@ class SDKs extends Action { $this ->desc('Generate Appwrite SDKs') - ->callback(fn () => $this->action()); + ->param('platform', null, new Nullable(new Text(256)), 'Selected Platform', optional: true) + ->param('sdk', null, new Nullable(new Text(256)), 'Selected SDK', optional:true) + ->param('version', null, new Nullable(new Text(256)), 'Selected SDK', optional:true) + ->param('git', null, new Nullable(new WhiteList(['yes', 'no'])), 'Should we use git push?', optional: true) + ->param('production', null, new Nullable(new WhiteList(['yes', 'no'])), 'Should we push to production?', optional:true) + ->param('message', null, new Nullable(new Text(256)), 'Commit Message', optional:true) + ->callback([$this, 'action']); } - public function action(): void + public function action(?string $selectedPlatform, ?string $selectedSDK, ?string $version, ?string $git, ?string $production, ?string $message) { - $platforms = Config::getParam('platforms'); - $selectedPlatform = Console::confirm('Choose Platform ("' . APP_PLATFORM_CLIENT . '", "' . APP_PLATFORM_SERVER . '", "' . APP_PLATFORM_CONSOLE . '" or "*" for all):'); - $selectedSDK = \strtolower(Console::confirm('Choose SDK ("*" for all):')); - $version = Console::confirm('Choose an Appwrite version'); - $git = (Console::confirm('Should we use git push? (yes/no)') == 'yes'); - $production = ($git) ? (Console::confirm('Type "Appwrite" to push code to production git repos') == 'Appwrite') : false; - $message = ($git) ? Console::confirm('Please enter your commit message:') : ''; + $selectedPlatform ??= Console::confirm('Choose Platform ("' . APP_PLATFORM_CLIENT . '", "' . APP_PLATFORM_SERVER . '", "' . APP_PLATFORM_CONSOLE . '" or "*" for all):'); + $selectedSDK ??= \strtolower(Console::confirm('Choose SDK ("*" for all):')); + $version ??= Console::confirm('Choose an Appwrite version'); + + $git ??= Console::confirm('Should we use git push? (yes/no)'); + $git = $git === 'yes'; + + if ($git) { + $production ??= Console::confirm('Type "Appwrite" to push code to production git repos'); + $production = $production === 'Appwrite'; + $message ??= Console::confirm('Please enter your commit message:'); + } if (!in_array($version, ['0.6.x', '0.7.x', '0.8.x', '0.9.x', '0.10.x', '0.11.x', '0.12.x', '0.13.x', '0.14.x', '0.15.x', '1.0.x', '1.1.x', '1.2.x', '1.3.x', '1.4.x', '1.5.x', '1.6.x', 'latest'])) { throw new \Exception('Unknown version given'); } + $platforms = Config::getParam('platforms'); foreach ($platforms as $key => $platform) { if ($selectedPlatform !== $key && $selectedPlatform !== '*') { continue; diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 48e4014f1e..6c11116a4c 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -495,29 +495,30 @@ class Deletes extends Action $limit = \count($projectCollectionIds) + 25; + $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; + while (true) { $collections = $dbForProject->listCollections($limit); foreach ($collections as $collection) { - if ($dsn->getHost() !== System::getEnv('_APP_DATABASE_SHARED_TABLES', '') || !\in_array($collection->getId(), $projectCollectionIds)) { - try { + try { + if ($projectTables || !\in_array($collection->getId(), $projectCollectionIds)) { $dbForProject->deleteCollection($collection->getId()); - } catch (Throwable $e) { - Console::error('Error deleting '.$collection->getId().' '.$e->getMessage()); - - /** - * Ignore junction tables; - */ - if (!preg_match('/^_\d+_\d+$/', $collection->getId())) { - throw $e; - } + } else { + $this->deleteByGroup($collection->getId(), [], database: $dbForProject); } - } else { - $this->deleteByGroup($collection->getId(), [], database: $dbForProject); + } catch (Throwable $e) { + Console::error('Error deleting '.$collection->getId().' '.$e->getMessage()); } } - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($sharedTables) { $collectionsIds = \array_map(fn ($collection) => $collection->getId(), $collections); if (empty(\array_diff($collectionsIds, $projectCollectionIds))) { @@ -571,10 +572,17 @@ class Deletes extends Action ], $dbForConsole); // Delete metadata table - if ($dsn->getHost() !== System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { - $dbForProject->deleteCollection('_metadata'); - } else { - $this->deleteByGroup('_metadata', [], $dbForProject); + if ($projectTables) { + $dbForProject->deleteCollection(Database::METADATA); + } elseif ($sharedTablesV1) { + $this->deleteByGroup(Database::METADATA, [], $dbForProject); + } elseif ($sharedTablesV2) { + $queries = \array_map( + fn ($id) => Query::notEqual('$id', $id), + $projectCollectionIds + ); + + $this->deleteByGroup(Database::METADATA, $queries, $dbForProject); } // Delete all storage directories diff --git a/src/Appwrite/Specification/Format/OpenAPI3.php b/src/Appwrite/Specification/Format/OpenAPI3.php index f7430ec70e..65df0cb694 100644 --- a/src/Appwrite/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/Specification/Format/OpenAPI3.php @@ -323,6 +323,7 @@ class OpenAPI3 extends Format case 'Utopia\Validator\JSON': case 'Utopia\Validator\Mock': case 'Utopia\Validator\Assoc': + case 'Appwrite\Functions\Validator\Payload': $param['default'] = (empty($param['default'])) ? new \stdClass() : $param['default']; $node['schema']['type'] = 'object'; $node['schema']['x-example'] = '{}'; diff --git a/src/Appwrite/Specification/Format/Swagger2.php b/src/Appwrite/Specification/Format/Swagger2.php index f2e324c71f..66a540a4d4 100644 --- a/src/Appwrite/Specification/Format/Swagger2.php +++ b/src/Appwrite/Specification/Format/Swagger2.php @@ -349,6 +349,7 @@ class Swagger2 extends Format case 'Utopia\Validator\JSON': case 'Utopia\Validator\Mock': case 'Utopia\Validator\Assoc': + case 'Appwrite\Functions\Validator\Payload': $node['type'] = 'object'; $node['default'] = (empty($param['default'])) ? new \stdClass() : $param['default']; $node['x-example'] = '{}'; diff --git a/src/Appwrite/Utopia/Response/Model/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index f4ff214d0b..ab2d7ba051 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -94,7 +94,7 @@ class Func extends Model ]) ->addRule('schedule', [ 'type' => self::TYPE_STRING, - 'description' => 'Function execution schedult in CRON format.', + 'description' => 'Function execution schedule in CRON format.', 'default' => '', 'example' => '5 4 * * *', ]) diff --git a/tests/e2e/Services/Databases/DatabasesCustomServerTest.php b/tests/e2e/Services/Databases/DatabasesCustomServerTest.php index 7cb8adb815..b501e2119e 100644 --- a/tests/e2e/Services/Databases/DatabasesCustomServerTest.php +++ b/tests/e2e/Services/Databases/DatabasesCustomServerTest.php @@ -1362,7 +1362,7 @@ class DatabasesCustomServerTest extends Scope ]); $this->assertEquals(400, $tooWide['headers']['status-code']); - $this->assertEquals('Attribute limit exceeded', $tooWide['body']['message']); + $this->assertEquals('attribute_limit_exceeded', $tooWide['body']['type']); } public function testIndexLimitException() diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 7b0847126c..326443afcf 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3727,11 +3727,11 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'teamId' => ID::unique(), - 'name' => 'Amating Team', + 'name' => 'Amazing Team', ]); $this->assertEquals(201, $team['headers']['status-code']); - $this->assertEquals('Amating Team', $team['body']['name']); + $this->assertEquals('Amazing Team', $team['body']['name']); $this->assertNotEmpty($team['body']['$id']); $teamId = $team['body']['$id']; @@ -3794,6 +3794,115 @@ class ProjectsConsoleClientTest extends Scope return $data; } + public function testDeleteSharedProject(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Amazing Team', + ]); + + $teamId = $team['body']['$id']; + + // Ensure deleting one project does not affect another project + $project1 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Amazing Project 1', + 'teamId' => $teamId, + 'region' => 'default' + ]); + + $project2 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Amazing Project 2', + 'teamId' => $teamId, + 'region' => 'default' + ]); + + $project1Id = $project1['body']['$id']; + $project2Id = $project2['body']['$id']; + + // Create user in each project + $key1 = $this->client->call(Client::METHOD_POST, '/projects/' . $project1Id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['users.read', 'users.write'], + ]); + + $user1 = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1Id, + 'x-appwrite-key' => $key1['body']['secret'], + ], [ + 'userId' => ID::unique(), + 'email' => 'test1@appwrite.io', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + + $key2 = $this->client->call(Client::METHOD_POST, '/projects/' . $project2Id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['users.read', 'users.write'], + ]); + + $user2 = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2Id, + 'x-appwrite-key' => $key2['body']['secret'], + ], [ + 'userId' => ID::unique(), + 'email' => 'test2@appwrite.io', + 'password' => 'password', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + + // Delete project 1 + $project1 = $this->client->call(Client::METHOD_DELETE, '/projects/' . $project1Id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $project1['headers']['status-code']); + + \sleep(3); + + // Ensure project 2 user is still there + $user2 = $this->client->call(Client::METHOD_GET, '/users/' . $user2['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2Id, + 'x-appwrite-key' => $key2['body']['secret'], + ]); + + $this->assertEquals(200, $user2['headers']['status-code']); + + // Create another user in project 2 in case read hits stale cache + $user3 = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2Id, + 'x-appwrite-key' => $key2['body']['secret'], + ], [ + 'userId' => ID::unique(), + 'email' => 'test3@appwrite.io' + ]); + + $this->assertEquals(201, $user3['headers']['status-code']); + } + /** * @depends testCreateProject */