diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cebdc02163..509e1f5b29 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -223,7 +223,7 @@ jobs: -e _APP_DATABASE_SHARED_TABLES \ -e _APP_DATABASE_SHARED_TABLES_V1 \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group devKeys,screenshots + appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group abuseEnabled,screenshots - name: Failure Logs if: failure() @@ -312,7 +312,7 @@ jobs: -e _APP_DATABASE_SHARED_TABLES \ -e _APP_DATABASE_SHARED_TABLES_V1 \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group devKeys,screenshots + appwrite test /usr/src/code/tests/e2e/Services/${{ matrix.service }} --debug --exclude-group abuseEnabled,screenshots - name: Failure Logs if: failure() @@ -322,8 +322,8 @@ jobs: echo "=== OpenRuntimes Executor Logs ===" docker compose logs openruntimes-executor - e2e_dev_keys: - name: E2E Service Test (Dev Keys) + e2e_abuse_enabled: + name: E2E Service Test (Abuse enabled) runs-on: ubuntu-latest needs: setup steps: @@ -344,7 +344,7 @@ jobs: docker compose up -d sleep 30 - - name: Run Projects tests with dev keys in dedicated table mode + - name: Run Projects tests in dedicated table mode run: | echo "Using project tables" export _APP_DATABASE_SHARED_TABLES= @@ -354,7 +354,7 @@ jobs: -e _APP_DATABASE_SHARED_TABLES \ -e _APP_DATABASE_SHARED_TABLES_V1 \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=devKeys + appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled - name: Failure Logs if: failure() @@ -364,8 +364,8 @@ jobs: echo "=== OpenRuntimes Executor Logs ===" docker compose logs openruntimes-executor - e2e_dev_keys_shared_mode: - name: E2E Shared Mode Service Test (Dev Keys) + e2e_abuse_enabled_shared_mode: + name: E2E Shared Mode Service Test (Abuse enabled) runs-on: ubuntu-latest needs: [ setup, check_database_changes ] if: needs.check_database_changes.outputs.database_changed == 'true' @@ -394,7 +394,7 @@ jobs: docker compose up -d sleep 30 - - name: Run Projects tests with dev keys in ${{ matrix.tables-mode }} table mode + - name: Run Projects tests in ${{ matrix.tables-mode }} table mode run: | if [ "${{ matrix.tables-mode }}" == "Shared V1" ]; then echo "Using shared tables V1" @@ -410,7 +410,7 @@ jobs: -e _APP_DATABASE_SHARED_TABLES \ -e _APP_DATABASE_SHARED_TABLES_V1 \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=devKeys + appwrite test /usr/src/code/tests/e2e/Services/Projects --debug --group=abuseEnabled - name: Failure Logs if: failure() @@ -420,7 +420,7 @@ jobs: echo "=== OpenRuntimes Executor Logs ===" docker compose logs openruntimes-executor - e2e_screenshots_keys: + e2e_screenshots: name: E2E Service Test (Site Screenshots) runs-on: ubuntu-latest needs: setup diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index e919df8e1a..8e214e7999 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -330,7 +330,18 @@ $platformCollections = [ 'default' => null, 'array' => false, 'filters' => ['datetime'], - ] + ], + [ + '$id' => ID::custom('labels'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ @@ -1402,21 +1413,21 @@ $platformCollections = [ '$id' => '_key_type', 'type' => Database::INDEX_KEY, 'attributes' => ['type'], - 'lengths' => [32], + 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], [ '$id' => '_key_trigger', 'type' => Database::INDEX_KEY, 'attributes' => ['trigger'], - 'lengths' => [32], + 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], [ '$id' => '_key_deploymentResourceType', 'type' => Database::INDEX_KEY, 'attributes' => ['deploymentResourceType'], - 'lengths' => [32], + 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], [ @@ -1458,23 +1469,23 @@ $platformCollections = [ '$id' => ID::custom('_key_owner'), 'type' => Database::INDEX_KEY, 'attributes' => ['owner'], - 'lengths' => [16], + 'lengths' => [], 'orders' => [Database::ORDER_ASC], ], [ - '$id' => ID::custom('_key_region'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['region'], - 'lengths' => [16], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => ID::custom('_key_piid_riid_rt'), + '$id' => ID::custom('_key_piid_diid_drt'), 'type' => Database::INDEX_KEY, 'attributes' => ['projectInternalId', 'deploymentInternalId', 'deploymentResourceType'], 'lengths' => [], 'orders' => [], ], + [ + '$id' => '_key_region_status_createdAt', + 'type' => Database::INDEX_KEY, + 'attributes' => ['region', 'status', '$createdAt'], + 'lengths' => [], + 'orders' => [], + ], ], ], diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d6b99f8855..2c481b500c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -959,6 +959,7 @@ App::post('/v1/account/sessions/email') )) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},email:{param-email}') + ->label('abuse-reset', [201]) ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') ->inject('request') @@ -1257,6 +1258,7 @@ App::post('/v1/account/sessions/token') )) ->label('abuse-limit', 10) ->label('abuse-key', 'ip:{ip},userId:{param-userId}') + ->label('abuse-reset', [201]) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('secret', '', new Text(256), 'Secret of a token generated by login methods. For example, the `createMagicURLToken` or `createPhoneToken` methods.') ->inject('request') @@ -2645,6 +2647,7 @@ App::put('/v1/account/sessions/magic-url') )) ->label('abuse-limit', 10) ->label('abuse-key', 'ip:{ip},userId:{param-userId}') + ->label('abuse-reset', [201]) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('secret', '', new Text(256), 'Valid verification token.') ->inject('request') diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 4a97118853..b4f75a9ee5 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -697,7 +697,7 @@ App::get('/v1/avatars/screenshots') } $client = new Client(); - $client->setTimeout(30); + $client->setTimeout(30 * 1000); // 30 seconds $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); // Convert indexed array to empty array (should not happen due to Assoc validator) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 49c0003588..74f734a856 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -204,6 +204,7 @@ App::post('/v1/projects') 'accessedAt' => DateTime::now(), 'search' => implode(' ', [$projectId, $name]), 'database' => $dsn, + 'labels' => [], ])); } catch (Duplicate) { throw new Exception(Exception::PROJECT_ALREADY_EXISTS); @@ -1500,6 +1501,7 @@ App::post('/v1/projects/:projectId/keys') Permission::update(Role::any()), Permission::delete(Role::any()), ], + // TODO: @hmacr Remove `projectInternalId` and `projectId` column writes before deleting the column. 'projectInternalId' => $project->getSequence(), 'projectId' => $project->getId(), 'resourceInternalId' => $project->getSequence(), @@ -1552,13 +1554,8 @@ App::get('/v1/projects/:projectId/keys') } $keys = $dbForPlatform->find('keys', [ - Query::or([ - Query::equal('projectInternalId', [$project->getSequence()]), - Query::and([ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]) - ]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), Query::limit(5000), ]); @@ -1599,13 +1596,8 @@ App::get('/v1/projects/:projectId/keys/:keyId') $key = $dbForPlatform->findOne('keys', [ Query::equal('$id', [$keyId]), - Query::or([ - Query::equal('projectInternalId', [$project->getSequence()]), - Query::and([ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]) - ]) + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), ]); if ($key->isEmpty()) { @@ -1649,13 +1641,8 @@ App::put('/v1/projects/:projectId/keys/:keyId') $key = $dbForPlatform->findOne('keys', [ Query::equal('$id', [$keyId]), - Query::or([ - Query::equal('projectInternalId', [$project->getSequence()]), - Query::and([ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]) - ]) + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), ]); if ($key->isEmpty()) { @@ -1706,13 +1693,8 @@ App::delete('/v1/projects/:projectId/keys/:keyId') $key = $dbForPlatform->findOne('keys', [ Query::equal('$id', [$keyId]), - Query::or([ - Query::equal('projectInternalId', [$project->getSequence()]), - Query::and([ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]) - ]) + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), ]); if ($key->isEmpty()) { diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 6f092a5d19..fd7dae55b4 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -200,8 +200,12 @@ App::post('/v1/mock/api-key-unprefixed') Permission::update(Role::any()), Permission::delete(Role::any()), ], + // TODO: @hmacr Remove `projectInternalId` and `projectId` column writes before deleting the column. 'projectInternalId' => $project->getSequence(), 'projectId' => $project->getId(), + 'resourceInternalId' => $project->getSequence(), + 'resourceId' => $project->getId(), + 'resourceType' => 'projects', 'name' => 'Outdated key', 'scopes' => $scopes, 'expire' => null, diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index b0c7aec41e..cd823c9c6b 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -845,7 +845,8 @@ App::shutdown() ->inject('queueForWebhooks') ->inject('queueForRealtime') ->inject('dbForProject') - ->action(function (App $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject) use ($parseLabel) { + ->inject('timelimit') + ->action(function (App $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, StatsUsage $queueForStatsUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, callable $timelimit) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -879,6 +880,41 @@ App::shutdown() $route = $utopia->getRoute(); $requestParams = $route->getParamsValues(); + /** + * Abuse labels + */ + $abuseEnabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; + $abuseResetCode = $route->getLabel('abuse-reset', []); + $abuseResetCode = \is_array($abuseResetCode) ? $abuseResetCode : [$abuseResetCode]; + + if ($abuseEnabled && \count($abuseResetCode) > 0 && \in_array($response->getStatusCode(), $abuseResetCode)) { + $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); + $abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; + + foreach ($abuseKeyLabel as $abuseKey) { + $start = $request->getContentRangeStart(); + $end = $request->getContentRangeEnd(); + $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); + $timeLimit + ->setParam('{projectId}', $project->getId()) + ->setParam('{userId}', $user->getId()) + ->setParam('{userAgent}', $request->getUserAgent('')) + ->setParam('{ip}', $request->getIP()) + ->setParam('{url}', $request->getHostname() . $route->getPath()) + ->setParam('{method}', $request->getMethod()) + ->setParam('{chunkId}', (int)($start / ($end + 1 - $start))); + + foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + if (!empty($value)) { + $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); + } + } + + $abuse = new Abuse($timeLimit); + $abuse->reset(); + } + } + /** * Audit labels */ diff --git a/app/init/database/filters.php b/app/init/database/filters.php index 49c13c9a0b..c9ad3fce03 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -136,13 +136,8 @@ Database::addFilter( function (mixed $value, Document $document, Database $database) { return $database ->find('keys', [ - Query::or([ - Query::equal('projectInternalId', [$document->getSequence()]), - Query::and([ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$document->getSequence()]), - ]) - ]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$document->getSequence()]), Query::limit(APP_LIMIT_SUBQUERY), ]); } diff --git a/app/init/registers.php b/app/init/registers.php index be2009449e..1b58c85aa4 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -360,6 +360,8 @@ $register->set('smtp', function () { $mail->SMTPSecure = System::getEnv('_APP_SMTP_SECURE', ''); $mail->SMTPAutoTLS = false; $mail->CharSet = 'UTF-8'; + $mail->Timeout = 10; /* Connection timeout */ + $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ $from = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); $email = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); diff --git a/composer.json b/composer.json index b760cac65c..c19ed94e75 100644 --- a/composer.json +++ b/composer.json @@ -45,9 +45,9 @@ "ext-sockets": "*", "appwrite/php-runtimes": "0.19.*", "appwrite/php-clamav": "2.0.*", - "utopia-php/abuse": "1.*", + "utopia-php/abuse": "1.*.*", "utopia-php/analytics": "0.10.*", - "utopia-php/audit": "2.0.2-rc1", + "utopia-php/audit": "2.0.2-rc3", "utopia-php/auth": "0.5.*", "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", @@ -59,12 +59,12 @@ "utopia-php/dns": "1.4.*", "utopia-php/dsn": "0.2.1", "utopia-php/framework": "0.33.*", - "utopia-php/fetch": "0.4.*", + "utopia-php/fetch": "0.5.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.20.*", - "utopia-php/migration": "1.3.*", + "utopia-php/migration": "1.*.*", "utopia-php/orchestration": "0.9.*", "utopia-php/platform": "0.7.*", "utopia-php/pools": "0.8.*", diff --git a/composer.lock b/composer.lock index bd73303a71..8b6f664dd9 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": "b873febd2b03c32ec61a57b690cc44a2", + "content-hash": "9bac4d8946e35357efa46fd087c9484e", "packages": [ { "name": "adhocore/jwt", @@ -69,16 +69,16 @@ }, { "name": "appwrite/appwrite", - "version": "15.1.0", + "version": "19.1.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-for-php.git", - "reference": "c438b3885071ac7c0329199dce5e6f6a24dd215b" + "reference": "8738e812062f899c85b2598eef43d6a247f08a56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/c438b3885071ac7c0329199dce5e6f6a24dd215b", - "reference": "c438b3885071ac7c0329199dce5e6f6a24dd215b", + "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/8738e812062f899c85b2598eef43d6a247f08a56", + "reference": "8738e812062f899c85b2598eef43d6a247f08a56", "shasum": "" }, "require": { @@ -87,7 +87,7 @@ "php": ">=7.1.0" }, "require-dev": { - "mockery/mockery": "^1.6.6", + "mockery/mockery": "^1.6.12", "phpunit/phpunit": "^10" }, "type": "library", @@ -104,10 +104,10 @@ "support": { "email": "team@appwrite.io", "issues": "https://github.com/appwrite/sdk-for-php/issues", - "source": "https://github.com/appwrite/sdk-for-php/tree/15.1.0", + "source": "https://github.com/appwrite/sdk-for-php/tree/19.1.0", "url": "https://appwrite.io/support" }, - "time": "2025-08-01T04:50:51+00:00" + "time": "2025-12-18T08:07:43+00:00" }, { "name": "appwrite/php-clamav", @@ -3455,24 +3455,25 @@ }, { "name": "utopia-php/abuse", - "version": "1.0.2", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "611fa66a97e87c0dbbc133a717d970da7a5ca828" + "reference": "3339d057c6bb1fa3e5ac5b2598923f6938425ec2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/611fa66a97e87c0dbbc133a717d970da7a5ca828", - "reference": "611fa66a97e87c0dbbc133a717d970da7a5ca828", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/3339d057c6bb1fa3e5ac5b2598923f6938425ec2", + "reference": "3339d057c6bb1fa3e5ac5b2598923f6938425ec2", "shasum": "" }, "require": { + "appwrite/appwrite": "19.*.*", "ext-curl": "*", "ext-pdo": "*", "ext-redis": "*", "php": ">=8.0", - "utopia-php/database": "*" + "utopia-php/database": "3.*.*" }, "require-dev": { "laravel/pint": "1.*", @@ -3500,9 +3501,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/1.0.2" + "source": "https://github.com/utopia-php/abuse/tree/1.2.0" }, - "time": "2025-10-20T07:18:33+00:00" + "time": "2026-01-05T21:29:10+00:00" }, { "name": "utopia-php/analytics", @@ -3552,23 +3553,23 @@ }, { "name": "utopia-php/audit", - "version": "2.0.2-rc1", + "version": "2.0.2-rc3", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "7b35dab40bce66bda56eeeacd2bbcbf1e823f05f" + "reference": "f60a298b516300f56a328403b334b7d62a96e7e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/7b35dab40bce66bda56eeeacd2bbcbf1e823f05f", - "reference": "7b35dab40bce66bda56eeeacd2bbcbf1e823f05f", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/f60a298b516300f56a328403b334b7d62a96e7e7", + "reference": "f60a298b516300f56a328403b334b7d62a96e7e7", "shasum": "" }, "require": { "php": ">=8.0", "utopia-php/database": "3.*", - "utopia-php/fetch": "^0.4.2", - "utopia-php/validators": "^0.1.0" + "utopia-php/fetch": "0.5.*", + "utopia-php/validators": "0.1.*" }, "require-dev": { "laravel/pint": "1.*", @@ -3595,9 +3596,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/2.0.2-rc1" + "source": "https://github.com/utopia-php/audit/tree/2.0.2-rc3" }, - "time": "2025-12-24T01:20:43+00:00" + "time": "2026-01-06T15:32:52+00:00" }, { "name": "utopia-php/auth", @@ -4167,23 +4168,23 @@ }, { "name": "utopia-php/emails", - "version": "0.6.3", + "version": "0.6.4", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "9524d7f7bd1651a06fef8a3d964f774b04fe2918" + "reference": "fb2bd5c428e88f645b0f7ede0dd29ac0d120ec52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/9524d7f7bd1651a06fef8a3d964f774b04fe2918", - "reference": "9524d7f7bd1651a06fef8a3d964f774b04fe2918", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/fb2bd5c428e88f645b0f7ede0dd29ac0d120ec52", + "reference": "fb2bd5c428e88f645b0f7ede0dd29ac0d120ec52", "shasum": "" }, "require": { "php": ">=8.0", "utopia-php/cli": "^0.15", "utopia-php/domains": "^0.9", - "utopia-php/fetch": "^0.4", + "utopia-php/fetch": "^0.5", "utopia-php/validators": "0.*" }, "require-dev": { @@ -4221,26 +4222,26 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.3" + "source": "https://github.com/utopia-php/emails/tree/0.6.4" }, - "time": "2025-11-26T12:27:47+00:00" + "time": "2025-12-18T16:36:50+00:00" }, { "name": "utopia-php/fetch", - "version": "0.4.2", + "version": "0.5.1", "source": { "type": "git", "url": "https://github.com/utopia-php/fetch.git", - "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77" + "reference": "a96a010e1c273f3888765449687baf58cbc61fcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/fetch/zipball/83986d1be75a2fae4e684107fe70dd78a8e19b77", - "reference": "83986d1be75a2fae4e684107fe70dd78a8e19b77", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd", + "reference": "a96a010e1c273f3888765449687baf58cbc61fcd", "shasum": "" }, "require": { - "php": ">=8.0" + "php": ">=8.1" }, "require-dev": { "laravel/pint": "^1.5.0", @@ -4260,9 +4261,9 @@ "description": "A simple library that provides an interface for making HTTP Requests.", "support": { "issues": "https://github.com/utopia-php/fetch/issues", - "source": "https://github.com/utopia-php/fetch/tree/0.4.2" + "source": "https://github.com/utopia-php/fetch/tree/0.5.1" }, - "time": "2025-04-25T13:48:02+00:00" + "time": "2025-12-18T16:25:10+00:00" }, { "name": "utopia-php/framework", @@ -4515,20 +4516,20 @@ }, { "name": "utopia-php/migration", - "version": "1.3.9", + "version": "1.3.10", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "c55ec67c74663190cda10fd79297422147be7e85" + "reference": "cb357c42a5a5614605b546effbea1204ed64c6b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/c55ec67c74663190cda10fd79297422147be7e85", - "reference": "c55ec67c74663190cda10fd79297422147be7e85", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/cb357c42a5a5614605b546effbea1204ed64c6b0", + "reference": "cb357c42a5a5614605b546effbea1204ed64c6b0", "shasum": "" }, "require": { - "appwrite/appwrite": "15.*", + "appwrite/appwrite": "19.*", "ext-curl": "*", "ext-openssl": "*", "php": ">=8.1", @@ -4564,9 +4565,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.3.9" + "source": "https://github.com/utopia-php/migration/tree/1.3.10" }, - "time": "2025-12-08T08:45:09+00:00" + "time": "2026-01-06T10:47:11+00:00" }, { "name": "utopia-php/mongo", @@ -4837,23 +4838,23 @@ }, { "name": "utopia-php/queue", - "version": "0.11.2", + "version": "0.11.3", "source": { "type": "git", "url": "https://github.com/utopia-php/queue.git", - "reference": "a854f7c4abc18e0eca55fc5608cd7088d71eb19f" + "reference": "f3b2623efe87595c9ed907b3efd587e77c622d3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/queue/zipball/a854f7c4abc18e0eca55fc5608cd7088d71eb19f", - "reference": "a854f7c4abc18e0eca55fc5608cd7088d71eb19f", + "url": "https://api.github.com/repos/utopia-php/queue/zipball/f3b2623efe87595c9ed907b3efd587e77c622d3d", + "reference": "f3b2623efe87595c9ed907b3efd587e77c622d3d", "shasum": "" }, "require": { "php": ">=8.3", "php-amqplib/php-amqplib": "^3.7", "utopia-php/cli": "0.15.*", - "utopia-php/fetch": "0.4.*", + "utopia-php/fetch": "0.5.*", "utopia-php/framework": "0.33.*", "utopia-php/pools": "0.8.*", "utopia-php/telemetry": "*" @@ -4897,9 +4898,9 @@ ], "support": { "issues": "https://github.com/utopia-php/queue/issues", - "source": "https://github.com/utopia-php/queue/tree/0.11.2" + "source": "https://github.com/utopia-php/queue/tree/0.11.3" }, - "time": "2025-12-17T09:32:35+00:00" + "time": "2025-12-19T10:56:22+00:00" }, { "name": "utopia-php/registry", @@ -5566,30 +5567,29 @@ }, { "name": "doctrine/instantiator", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.4" }, "require-dev": { - "doctrine/coding-standard": "^11", + "doctrine/coding-standard": "^14", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -5616,7 +5616,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" }, "funding": [ { @@ -5632,7 +5632,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:23:10+00:00" + "time": "2026-01-05T06:47:08+00:00" }, { "name": "doctrine/lexer", @@ -5713,16 +5713,16 @@ }, { "name": "laravel/pint", - "version": "v1.26.0", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", - "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", + "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", "shasum": "" }, "require": { @@ -5733,9 +5733,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.90.0", - "illuminate/view": "^12.40.1", - "larastan/larastan": "^3.8.0", + "friendsofphp/php-cs-fixer": "^3.92.4", + "illuminate/view": "^12.44.0", + "larastan/larastan": "^3.8.1", "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.3", @@ -5776,7 +5776,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-11-25T21:15:52+00:00" + "time": "2026-01-05T16:49:17+00:00" }, { "name": "matthiasmullie/minify", diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 1d202b4948..e38a56bd2b 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -939,7 +939,7 @@ class Builds extends Action } $client = new FetchClient(); - $client->setTimeout(\intval($resource->getAttribute('timeout', '15'))); + $client->setTimeout(\intval($resource->getAttribute('timeout', '15')) * 1000); $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON); $bucket = Authorization::skip(fn () => $dbForPlatform->getDocument('buckets', 'screenshots')); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php new file mode 100644 index 0000000000..1a06c1ee84 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Labels/Update.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/projects/:projectId/labels') + ->desc('Update project labels') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'updateLabels', + description: <<param('projectId', '', new UID(), 'Project unique ID.') + ->param('labels', [], new ArrayList(new Text(36, allowList: [...Text::NUMBERS, ...Text::ALPHABET_UPPER, ...Text::ALPHABET_LOWER]), APP_LIMIT_ARRAY_LABELS_SIZE), 'Array of project labels. Replaces the previous labels. Maximum of ' . APP_LIMIT_ARRAY_LABELS_SIZE . ' labels are allowed, each up to 36 alphanumeric characters long.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + /** + * @param array $labels + */ + public function action( + string $projectId, + array $labels, + Response $response, + Database $dbForPlatform + ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project->setAttribute('labels', (array) \array_values(\array_unique($labels))); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 2a0dd0aa60..cce05a9570 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -7,6 +7,7 @@ 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\Labels\Update as UpdateProjectLabels; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Utopia\Platform\Service; @@ -22,5 +23,6 @@ class Http extends Service $this->addAction(DeleteDevKey::getName(), new DeleteDevKey()); $this->addAction(ListProjects::getName(), new ListProjects()); + $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); } } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 5c36fa7f7a..983e13b295 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -148,7 +148,7 @@ class Deletes extends Action break; case DELETE_TYPE_AUDIT: if (!$project->isEmpty()) { - $this->deleteAuditLogs($project, $auditRetention, $getAudit); + $this->deleteAuditLogs($project, $getAudit, $auditRetention); } break; case DELETE_TYPE_REALTIME: @@ -568,13 +568,8 @@ class Deletes extends Action // Delete Keys $this->deleteByGroup('keys', [ - Query::or([ - Query::equal('projectInternalId', [$projectInternalId]), - Query::and([ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$projectInternalId]), - ]) - ]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$projectInternalId]), Query::orderAsc() ], $dbForPlatform); @@ -783,14 +778,13 @@ class Deletes extends Action } /** - * @param Database $dbForPlatform - * @param callable $getProjectDB - * @param string $auditRetention + * @param Document $project * @param callable $getAudit + * @param string $auditRetention * @return void * @throws Exception */ - private function deleteAuditLogs(Document $project, string $auditRetention, callable $getAudit): void + private function deleteAuditLogs(Document $project, callable $getAudit, string $auditRetention): void { $projectId = $project->getId(); /** @var Audit $audit */ diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 01448620f3..b1f17fc648 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -68,7 +68,8 @@ class Mails extends Action throw new Exception('Skipped mail processing. No SMTP configuration has been set.'); } - $log->addTag('type', empty($smtp) ? 'cloud' : 'smtp'); + $type = empty($smtp) ? 'cloud' : 'smtp'; + $log->addTag('type', $type); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; $hostname = System::getEnv('_APP_CONSOLE_DOMAIN'); @@ -182,6 +183,9 @@ class Mails extends Action try { $mail->send(); } catch (\Throwable $error) { + if ($type === 'smtp') { + throw new Exception('Error sending mail: ' . $error->getMessage(), 401); + } throw new Exception('Error sending mail: ' . $error->getMessage(), 500); } } @@ -209,6 +213,8 @@ class Mails extends Action $mail->SMTPSecure = $smtp['secure']; $mail->SMTPAutoTLS = false; $mail->CharSet = 'UTF-8'; + $mail->Timeout = 10; /* Connection timeout */ + $mail->getSMTPInstance()->Timelimit = 30; /* Timeout for each individual SMTP command (e.g. HELO, EHLO, etc.) */ $mail->setFrom($smtp['senderEmail'], $smtp['senderName']); diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index 967dbc59a4..1c3db8d9c9 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -111,13 +111,8 @@ class StatsResources extends Action Query::equal('projectInternalId', [$project->getSequence()]) ]); $keys = $dbForPlatform->count('keys', [ - Query::or([ - Query::equal('projectInternalId', [$project->getSequence()]), - Query::and([ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]) - ]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), ]); $domains = $dbForPlatform->count('rules', [ diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php index d179703274..d96e373949 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php @@ -6,7 +6,8 @@ class Projects extends Base { public const ALLOWED_ATTRIBUTES = [ 'name', - 'teamId' + 'teamId', + 'labels', ]; /** diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 7641e96090..c516aab73f 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -276,6 +276,13 @@ class Project extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('labels', [ + 'type' => self::TYPE_STRING, + 'description' => 'Labels for the project.', + 'default' => [], + 'example' => ['vip'], + 'array' => true, + ]) ; $services = Config::getParam('services', []); diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index 0c9d481371..fa0d5f0fab 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -363,4 +363,77 @@ trait AccountBase $this->assertEquals($response['headers']['status-code'], 201); $this->assertEquals('191.0.113.195', $response['body']['clientIp'] ?? $response['body']['ip'] ?? ''); } + + /** + * @group abuseEnabled + */ + public function testAccountAbuseReset(): void + { + $email = \uniqid() . '.abuse.reset.test@example.com'; + $password = 'password'; + $account = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Abuse Reset Test', + ]); + + $this->assertEquals($account['headers']['status-code'], 201); + + // 20 successful requests won't get blocked + for ($i = 0; $i < 20; $i++) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($session['headers']['status-code'], 201); + } + + // 10 failures are OK + for ($i = 0; $i < 10; $i++) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => 'wrongPassword', + ]); + + $this->assertEquals($session['headers']['status-code'], 401); + } + + // 11th request gets limited + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => 'wrongPassword', + ]); + + $this->assertEquals($session['headers']['status-code'], 429); + + // Even correct password is now blocked, correctness doesn't matter + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($session['headers']['status-code'], 429); + } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index fae6031672..e31331574f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -4783,7 +4783,7 @@ class ProjectsConsoleClientTest extends Scope */ /** - * @group devKeys + * @group abuseEnabled */ public function testCreateProjectDevKey(): void { @@ -4844,7 +4844,7 @@ class ProjectsConsoleClientTest extends Scope /** - * @group devKeys + * @group abuseEnabled */ public function testListProjectDevKey(): void { @@ -4935,7 +4935,7 @@ class ProjectsConsoleClientTest extends Scope /** - * @group devKeys + * @group abuseEnabled */ public function testGetProjectDevKey(): void { @@ -4979,7 +4979,7 @@ class ProjectsConsoleClientTest extends Scope } /** - * @group devKeys + * @group abuseEnabled */ public function testGetDevKeyWithSdks(): void { @@ -5036,7 +5036,7 @@ class ProjectsConsoleClientTest extends Scope } /** - * @group devKeys + * @group abuseEnabled */ public function testNoHostValidationWithDevKey(): void { @@ -5117,7 +5117,7 @@ class ProjectsConsoleClientTest extends Scope } /** - * @group devKeys + * @group abuseEnabled */ public function testCorsWithDevKey(): void { @@ -5174,7 +5174,7 @@ class ProjectsConsoleClientTest extends Scope } /** - * @group devKeys + * @group abuseEnabled */ public function testNoRateLimitWithDevKey(): void { @@ -5279,7 +5279,7 @@ class ProjectsConsoleClientTest extends Scope } /** - * @group devKeys + * @group abuseEnabled */ public function testUpdateProjectDevKey(): void { @@ -5324,7 +5324,7 @@ class ProjectsConsoleClientTest extends Scope } /** - * @group devKeys + * @group abuseEnabled */ public function testDeleteProjectDevKey(): void { @@ -5382,4 +5382,216 @@ class ProjectsConsoleClientTest extends Scope /** * Devkeys Tests ends here ------------------------------------------------ */ + + public function testProjectLabels(): void + { + // Setup: Prepare team + $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' => 'Query Select Test Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + // Setup: Prepare project + $project = $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' => 'Test project - Labels 1', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(0, $project['body']['labels']); + $projectId = $project['body']['$id']; + + // Apply labels + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['vip', 'imagine', 'blocked'] + ]); + + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(3, $project['body']['labels']); + $this->assertEquals('vip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + $this->assertEquals('blocked', $project['body']['labels'][2]); + + // Update labels + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['nonvip', 'imagine'] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(2, $project['body']['labels']); + $this->assertEquals('nonvip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + + // Filter by labels + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['nonvip'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(0, $projects['body']['total']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['nonvip', 'imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + + // Setup: Second project with only imagine label + $project = $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' => 'Test project - Labels 2', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(0, $project['body']['labels']); + $projectId2 = $project['body']['$id']; + + $project = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId2 . '/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'labels' => ['vip', 'imagine'] + ]); + $this->assertEquals(200, $project['headers']['status-code']); + $this->assertIsArray($project['body']['labels']); + $this->assertCount(2, $project['body']['labels']); + $this->assertEquals('vip', $project['body']['labels'][0]); + $this->assertEquals('imagine', $project['body']['labels'][1]); + + // List of imagine has both + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(2, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); + + // List of vip only has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); + + // List of vip and imagine has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip'])->toString(), + Query::contains('labels', ['imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(1, $projects['body']['total']); + $this->assertEquals($projectId2, $projects['body']['projects'][0]['$id']); + + // List of vip or imagine has second + $projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('labels', ['vip', 'imagine'])->toString(), + ] + ]); + $this->assertEquals(200, $projects['headers']['status-code']); + $this->assertEquals(2, $projects['body']['total']); + $this->assertEquals($projectId, $projects['body']['projects'][0]['$id']); + $this->assertEquals($projectId2, $projects['body']['projects'][1]['$id']); + + // Cleanup + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId2, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + } }