diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02d021f1a..e7307d3c3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -429,6 +429,8 @@ jobs: include: - service: Databases runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 - service: Sites runner: blacksmith-4vcpu-ubuntu-2404 - service: Functions @@ -439,6 +441,10 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - service: TablesDB runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 + - service: Migrations + paratest_processes: 1 steps: - name: Checkout repository uses: actions/checkout@v6 @@ -499,7 +505,7 @@ jobs: with: max_attempts: 2 retry_wait_seconds: 60 - timeout_minutes: 20 + timeout_minutes: ${{ matrix.timeout_minutes || 20 }} job_id: ${{ job.check_run_id }} github_token: ${{ secrets.GITHUB_TOKEN }} test_dir: tests/e2e/Services/${{ matrix.service }} @@ -512,9 +518,14 @@ jobs: Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;; esac + PARATEST_PROCESSES="${{ matrix.paratest_processes }}" + if [ -z "$PARATEST_PROCESSES" ]; then + PARATEST_PROCESSES="$(nproc)" + fi + docker compose exec -T \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml + appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - name: Failure Logs if: failure() @@ -692,10 +703,11 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml - curl https://appwrite.io/install/env -o .env + LATEST_TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/appwrite/appwrite/releases/latest | jq -r .tag_name) + echo "Latest release tag: $LATEST_TAG" + curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/docker-compose.yml" -o docker-compose.yml + curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/.env" -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d sleep 10 diff --git a/README-CN.md b/README-CN.md index 2c7402f1ef..212b5bb08d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 31076ffa31..88d527f060 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..50b0cb3dfc 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,9 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'policies.write', + 'templates.read', + 'templates.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..c5fba3ed2b 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,16 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "policies.write" => [ + "description" => + "Access to update project\'s policies", + ], + "templates.read" => [ + "description" => + "Access to read project\'s templates", + ], + "templates.write" => [ + "description" => + "Access to create, update, and delete project\'s templates", + ], ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5c09a8070f..6783b88f82 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -131,9 +131,6 @@ $createSession = function (string $userId, string $secret, Request $request, Res }); $provider = match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_VERIFICATION, - TOKEN_TYPE_RECOVERY, - TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, TOKEN_TYPE_OAUTH2 => $oauthProvider, @@ -350,15 +347,15 @@ Http::post('/v1/account') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -468,7 +465,7 @@ Http::delete('/v1/account') ->groups(['api', 'account']) ->label('scope', 'account') ->label('audits.event', 'user.delete') - ->label('audits.resource', 'user/{response.$id}') + ->label('audits.resource', 'user/{user.$id}') ->label('sdk', new Method( namespace: 'account', group: 'account', @@ -855,7 +852,7 @@ Http::patch('/v1/account/sessions/:sessionId') throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } - if (!empty($provider) && $className !== null && \class_exists($className)) { + if (!empty($provider) && \class_exists($className)) { $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; @@ -1628,7 +1625,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email + if ($user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email if (empty($email)) { $failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.'); } @@ -1645,7 +1642,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is a user with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $userWithEmail = $dbForProject->findOne('users', [ Query::equal('email', [$email]), ]); @@ -1658,7 +1655,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is an identity with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $identityWithMatchingEmail = $dbForProject->findOne('identities', [ Query::equal('providerEmail', [$email]), ]); @@ -1670,7 +1667,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // Last option -> create the user + if ($user->isEmpty()) { // Last option -> create the user $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -1703,15 +1700,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1845,15 +1842,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1978,7 +1975,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')); } - if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) { + if (isset($sessionUpgrade) && isset($session)) { foreach ($user->getAttribute('targets', []) as $target) { if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) { continue; @@ -2202,15 +2199,15 @@ Http::post('/v1/account/tokens/magic-url') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2329,8 +2326,8 @@ Http::post('/v1/account/tokens/magic-url') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2339,8 +2336,13 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2357,8 +2359,13 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2366,7 +2373,8 @@ Http::post('/v1/account/tokens/magic-url') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -2513,15 +2521,15 @@ Http::post('/v1/account/tokens/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2648,7 +2656,8 @@ Http::post('/v1/account/tokens/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2657,8 +2666,13 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2675,8 +2689,13 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2684,7 +2703,8 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -3292,7 +3312,7 @@ Http::patch('/v1/account/password') } $history[] = $newPassword; - $history = array_slice($history, (count($history) - $historyLimit), $historyLimit); + $history = array_slice($history, -$historyLimit); } if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) { @@ -3416,15 +3436,15 @@ Http::patch('/v1/account/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3456,9 +3476,6 @@ Http::patch('/v1/account/email') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -3546,9 +3563,6 @@ Http::patch('/v1/account/phone') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -3772,7 +3786,8 @@ Http::post('/v1/account/recovery') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -3781,8 +3796,13 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -3799,8 +3819,13 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -3808,7 +3833,8 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4091,7 +4117,8 @@ Http::post('/v1/account/verifications/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -4100,8 +4127,13 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -4118,8 +4150,13 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -4127,7 +4164,8 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4638,7 +4676,7 @@ Http::delete('/v1/account/targets/:targetId/push') ->groups(['api', 'account']) ->label('scope', 'targets.write') ->label('audits.event', 'target.delete') - ->label('audits.resource', 'target/response.$id') + ->label('audits.resource', 'target/{request.targetId}') ->label('event', 'users.[userId].targets.[targetId].delete') ->label('sdk', new Method( namespace: 'account', diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index f817724314..8725574ca4 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -480,7 +480,6 @@ Http::post('/v1/messaging/providers/msg91') $enabled === true && \array_key_exists('senderId', $credentials) && \array_key_exists('authKey', $credentials) - && \array_key_exists('from', $options) ) { $enabled = true; } else { @@ -3184,10 +3183,6 @@ Http::post('/v1/messaging/messages/email') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - $mergedTargets = \array_merge($targets, $cc, $bcc); if (!empty($mergedTargets)) { @@ -3363,10 +3358,6 @@ Http::post('/v1/messaging/messages/sms') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -3504,10 +3495,6 @@ Http::post('/v1/messaging/messages/push') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -4630,7 +4617,7 @@ Http::delete('/v1/messaging/messages/:messageId') if (!empty($scheduleId)) { try { $dbForPlatform->deleteDocument('schedules', $scheduleId); - } catch (Exception) { + } catch (\Throwable) { // Ignore } } diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 4c541d2817..7338197511 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -51,7 +51,8 @@ function getDatabaseTransferResourceServices(string $databaseType) DATABASE_TYPE_LEGACY, DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, - DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), }; } diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index 054a7c8f0d..544beade77 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -113,11 +113,12 @@ Http::get('/v1/project/usage') $factor = match ($period) { '1h' => 3600, '1d' => 86400, + default => throw new \LogicException('Unsupported period: ' . $period), }; $limit = match ($period) { '1h' => (new DateTime($startDate))->diff(new DateTime($endDate))->days * 24, - '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days + '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days, }; $format = match ($period) { diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..bd5d0504cf 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -3,29 +3,21 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; -use Appwrite\Event\Mail; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; -use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; -use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\UID; -use Utopia\Emails\Validator\Email; use Utopia\Http\Http; -use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Hostname; -use Utopia\Validator\Integer; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -138,162 +130,6 @@ Http::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/session-alerts') - ->desc('Update project sessions emails') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionAlerts', - description: '/docs/references/projects/update-session-alerts.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['sessionAlerts'] = $alerts; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/memberships-privacy') - ->desc('Update project memberships privacy attributes') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMembershipsPrivacy', - description: '/docs/references/projects/update-memberships-privacy.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('userName', true, new Boolean(true), 'Set to true to show userName to members of a team.') - ->param('userEmail', true, new Boolean(true), 'Set to true to show email to members of a team.') - ->param('mfa', true, new Boolean(true), 'Set to true to show mfa to members of a team.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $userName, bool $userEmail, bool $mfa, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $mfa; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/limit') - ->desc('Update project users limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthLimit', - description: '/docs/references/projects/update-auth-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(0, APP_LIMIT_USERS), 'Set the max number of users allowed in this project. Use 0 for unlimited.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['limit'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/duration') - ->desc('Update project authentication duration') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthDuration', - description: '/docs/references/projects/update-auth-duration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['duration'] = $duration; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/:method') ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') ->groups(['api', 'projects']) @@ -321,7 +157,6 @@ Http::patch('/v1/projects/:projectId/auth/:method') $project = $dbForPlatform->getDocument('projects', $projectId); $auth = Config::getParam('auth')[$method] ?? []; $authKey = $auth['key'] ?? ''; - $status = ($status === '1' || $status === 'true' || $status === 1 || $status === true); if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); @@ -335,158 +170,6 @@ Http::patch('/v1/projects/:projectId/auth/:method') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/password-history') - ->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordHistory', - description: '/docs/references/projects/update-auth-password-history.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordHistory'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/password-dictionary') - ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordDictionary', - description: '/docs/references/projects/update-auth-password-dictionary.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordDictionary'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/personal-data') - ->desc('Update personal data check') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updatePersonalDataCheck', - description: '/docs/references/projects/update-personal-data-check.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to check a password for similarity with personal data. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['personalDataCheck'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/max-sessions') - ->desc('Update project user sessions limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthSessionsLimit', - description: '/docs/references/projects/update-auth-sessions-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['maxSessions'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) @@ -619,471 +302,29 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -// CUSTOM SMTP and Templates -Http::patch('/v1/projects/:projectId/smtp') - ->desc('Update SMTP') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmtp', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMTP', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMTP', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Enable custom SMTP service') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name', true) - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - // Ensure required params for when enabling SMTP - if ($enabled) { - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.'); - } elseif (empty($senderEmail)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.'); - } elseif (empty($host)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.'); - } elseif (empty($port)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.'); - } - } - - // validate SMTP settings - if ($enabled) { - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - } - - // Save SMTP settings - if ($enabled) { - $smtp = [ - 'enabled' => $enabled, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - } else { - $smtp = [ - 'enabled' => false - ]; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::post('/v1/projects/:projectId/smtp/tests') - ->desc('Create SMTP test') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSmtpTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.createSMTPTest', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSMTPTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') - ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender') - ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('queueForMails') - ->inject('plan') - ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; - - $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); - $template - ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); - - foreach ($emails as $email) { - $queueForMails - ->setSmtpHost($host) - ->setSmtpPort($port) - ->setSmtpUsername($username) - ->setSmtpPassword($password) - ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName) - ->setRecipient($email) - ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') - ->setBody($template->render()) - ->setVariables([]) - ->setSubject($subject) - ->trigger(); - } - - $response->noContent(); - }); - -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - +// Backwards compatibility Http::delete('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - unset($templates['email.' . $type . '-' . $locale]); $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/auth/session-invalidation') - ->desc('Update invalidate session option of the project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionInvalidation', - description: '/docs/references/projects/update-session-invalidation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['invalidateSessions'] = $enabled; - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); + $response->noContent(); }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 63975ced05..1fbc04bc05 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1530,7 +1530,7 @@ Http::patch('/v1/users/:userId/email') Query::equal('identifier', [$email]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1590,9 +1590,6 @@ Http::patch('/v1/users/:userId/email') 'emailIsDisposable' => $user->getAttribute('emailIsDisposable'), 'emailIsFree' => $user->getAttribute('emailIsFree'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -1676,7 +1673,7 @@ Http::patch('/v1/users/:userId/phone') Query::equal('identifier', [$number]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1686,9 +1683,6 @@ Http::patch('/v1/users/:userId/phone') 'phone' => $phoneValue, 'phoneVerification' => $user->getAttribute('phoneVerification'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { @@ -2247,8 +2241,8 @@ Http::delete('/v1/users/:userId/mfa/authenticators/:type') ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'users.write') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{request.userId}') + ->label('audits.userId', '{request.userId}') ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk', [ new Method( @@ -2835,6 +2829,7 @@ Http::get('/v1/users/usage') $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/app/controllers/general.php b/app/controllers/general.php index 8f6f2dc549..fe664fbd71 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -27,6 +27,7 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; use Appwrite\Utopia\Request\Filters\V22 as RequestV22; +use Appwrite\Utopia\Request\Filters\V23 as RequestV23; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -35,6 +36,7 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; +use Appwrite\Utopia\Response\Filters\V23 as ResponseV23; use Appwrite\Utopia\View; use Executor\Executor; use Swoole\Http\Request as SwooleRequest; @@ -67,7 +69,7 @@ Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, GeoRecord $geoRecord, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } @@ -200,12 +202,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId)); } - if ($deployment->getAttribute('resourceType', '') === 'functions') { - $type = 'function'; - } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { - $type = 'site'; - } - if ($deployment->isEmpty()) { $resourceType = $rule->getAttribute('deploymentResourceType', ''); $resourceId = $rule->getAttribute('deploymentResourceId', ''); @@ -215,6 +211,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $exception; } + if ($deployment->getAttribute('resourceType', '') === 'functions') { + $type = 'function'; + } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { + $type = 'site'; + } else { + throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown deployment resource type', view: $errorView); + } + $resource = $type === 'function' ? $authorization->skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) : $authorization->skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', ''))); @@ -302,13 +306,13 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } - $body = $swooleRequest->getContent() ?? ''; + $body = $swooleRequest->getContent() ?: ''; $method = $swooleRequest->server['request_method']; $requestHeaders = $request->getHeaders(); if ($resource->isEmpty() || !$resource->getAttribute('enabled')) { - if ($type === 'functions') { + if ($type === 'function') { throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView); } else { throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView); @@ -330,7 +334,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $runtime = match ($type) { 'function' => $runtimes[$resource->getAttribute('runtime')] ?? null, 'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, - default => null }; // Static site enforced runtime @@ -453,10 +456,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -672,9 +675,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($logs) && \strlen($logs) > $maxLogLength) { $warningMessage = "[WARNING] Logs truncated. The output exceeded {$maxLogLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxLogLength - $warningLength); - $logs = $warningMessage . ($maxContentLength > 0 ? \substr($logs, -$maxContentLength) : ''); + $maxContentLength = $maxLogLength - \strlen($warningMessage); + $logs = $warningMessage . \substr($logs, -$maxContentLength); } // Truncate errors if they exceed the limit @@ -683,9 +685,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($errors) && \strlen($errors) > $maxErrorLength) { $warningMessage = "[WARNING] Errors truncated. The output exceeded {$maxErrorLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxErrorLength - $warningLength); - $errors = $warningMessage . ($maxContentLength > 0 ? \substr($errors, -$maxContentLength) : ''); + $maxContentLength = $maxErrorLength - \strlen($warningMessage); + $errors = $warningMessage . \substr($errors, -$maxContentLength); } /** Update execution status */ $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; @@ -713,14 +714,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $th; } } finally { - if ($type === 'function' || $type === 'site') { - $bus->dispatch(new ExecutionCompleted( - execution: $execution->getArrayCopy(), - project: $project->getArrayCopy(), - spec: $spec, - resource: $resource->getArrayCopy(), - )); - } + $bus->dispatch(new ExecutionCompleted( + execution: $execution->getArrayCopy(), + project: $project->getArrayCopy(), + spec: $spec, + resource: $resource->getArrayCopy(), + )); } $execution->setAttribute('logs', ''); @@ -846,7 +845,7 @@ Http::init() /* * Appwrite Router */ - $hostname = $request->getHostname() ?? ''; + $hostname = $request->getHostname(); $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { @@ -891,6 +890,9 @@ Http::init() if (version_compare($requestFormat, '1.9.1', '<')) { $request->addFilter(new RequestV22()); } + if (version_compare($requestFormat, '1.9.2', '<')) { + $request->addFilter(new RequestV23()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -915,6 +917,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.2', '<')) { + $response->addFilter(new ResponseV23()); + } if (version_compare($responseFormat, '1.9.1', '<')) { $response->addFilter(new ResponseV22()); } @@ -1493,9 +1498,9 @@ Http::error() ->setParam('development', Http::isDevelopment()) ->setParam('projectName', $project->getAttribute('name')) ->setParam('projectURL', $project->getAttribute('url')) - ->setParam('message', $output['message'] ?? '') - ->setParam('type', $output['type'] ?? '') - ->setParam('code', $output['code'] ?? '') + ->setParam('message', $output['message']) + ->setParam('type', $output['type']) + ->setParam('code', $output['code']) ->setParam('trace', $output['trace'] ?? []) ->setParam('exception', $error); @@ -1610,7 +1615,7 @@ Http::get('/.well-known/acme-challenge/*') throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND, 'Unknown path'); } - if (!\substr($absolute, 0, \strlen($base)) === $base) { + if (\substr($absolute, 0, \strlen($base)) !== $base) { throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, 'Invalid path'); } @@ -1689,7 +1694,7 @@ Http::get('/_appwrite/authorize') ->inject('previewHostname') ->action(function (Request $request, Response $response, string $previewHostname) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 99713af430..4e92b3482d 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -251,7 +251,7 @@ Http::get('/v1/mock/github/callback') $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bba00bede1..8b8c7ee066 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -46,7 +46,7 @@ use Utopia\Validator\WhiteList; $parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) { preg_match_all('/{(.*?)}/', $label, $matches); - foreach ($matches[1] ?? [] as $pos => $match) { + foreach ($matches[1] as $pos => $match) { $find = $matches[0][$pos]; $parts = explode('.', $match); @@ -54,8 +54,8 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose"); } - $namespace = $parts[0] ?? ''; - $replace = $parts[1] ?? ''; + $namespace = $parts[0]; + $replace = $parts[1]; $params = match ($namespace) { 'user' => (array) $user, @@ -263,8 +263,7 @@ Http::init() $userClone->setAttribute('type', match ($apiKey->getType()) { API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT, API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, - API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, - default => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_ORGANIZATION, }); $auditContext->user = $userClone; } @@ -385,7 +384,7 @@ Http::init() } // Step 6: Update project and user last activity - if (! $project->isEmpty() && $project->getId() !== 'console') { + if ($project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ @@ -415,9 +414,6 @@ Http::init() } // Steps 7-9: Access Control - Method, Namespace and Scope Validation - /** - * @var ?Method $method - */ $method = $route->getLabel('sdk', false); // Take the first method if there's more than one, @@ -646,7 +642,7 @@ Http::init() if (! empty($data) && ! $cacheLog->isEmpty()) { $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); - $type = $parts[0] ?? null; + $type = $parts[0]; if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) { $bucketId = $parts[1] ?? null; @@ -757,7 +753,12 @@ Http::shutdown() ->inject('project') ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { - $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0; + + if ($sessionLimit === 0) { + return; + } + $session = $response->getPayload(); $userId = $session['userId'] ?? ''; if (empty($userId)) { @@ -937,7 +938,7 @@ Http::shutdown() } $auditUser = $auditContext->user; - if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + if (! empty($auditContext->resource) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..8eacf2fe12 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -1,6 +1,7 @@ findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -139,7 +139,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (!\in_array($sdk, $sdks, true)) { diff --git a/app/init/registers.php b/app/init/registers.php index 8a5f7ce292..e9ed5e7733 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -70,7 +70,7 @@ $register->set('logger', function () { $providerConfig = match ($providerName) { 'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',], - 'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''], + 'logowl' => ['ticket' => $configChunks[0], 'host' => ''], default => ['key' => $providerConfig], }; } @@ -248,11 +248,11 @@ $register->set('pools', function () { $poolSize = max(1, (int)($instanceConnections / $workerCount)); foreach ($connections as $key => $connection) { - $type = $connection['type'] ?? ''; - $multiple = $connection['multiple'] ?? false; - $schemes = $connection['schemes'] ?? []; + $type = $connection['type']; + $multiple = $connection['multiple']; + $schemes = $connection['schemes']; $config = []; - $dsns = explode(',', $connection['dsns'] ?? ''); + $dsns = explode(',', $connection['dsns']); foreach ($dsns as &$dsn) { $dsn = explode('=', $dsn); $name = ($multiple) ? $key . '_' . $dsn[0] : $key; @@ -317,7 +317,7 @@ $register->set('pools', function () { )); }); }, - 'redis' => function () use ($dsnHost, $dsnPort, $dsnPass) { + default => function () use ($dsnHost, $dsnPort, $dsnPass) { $redis = new \Redis(); @$redis->pconnect($dsnHost, (int)$dsnPort); if ($dsnPass) { @@ -327,7 +327,6 @@ $register->set('pools', function () { return $redis; }, - default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid scheme'), }; $poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool(); diff --git a/app/init/resources.php b/app/init/resources.php index 087e38c3b4..ab241035fb 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -266,7 +266,7 @@ function getDevice(string $root, string $connection = ''): Device return new Local($root); } } else { - switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL) ?? '')) { + switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL))) { case Storage::DEVICE_LOCAL: default: return new Local($root); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 996ccf9cb9..b4ad91b756 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -378,7 +378,7 @@ return function (Container $container): void { return $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -481,14 +481,10 @@ return function (Container $container): void { } // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies - if ($response) { // if in http context - add debug header - $response->addHeader('X-Debug-Fallback', 'false'); - } + $response->addHeader('X-Debug-Fallback', 'false'); if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { - if ($response) { - $response->addHeader('X-Debug-Fallback', 'true'); - } + $response->addHeader('X-Debug-Fallback', 'true'); $fallback = $request->getHeader('x-fallback-cookies', ''); $fallback = \json_decode($fallback, true); $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); @@ -1087,7 +1083,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (! in_array($sdk, $sdks)) { diff --git a/app/init/worker/message.php b/app/init/worker/message.php index c505d4cb3a..dfe6af9bd9 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -368,7 +368,7 @@ return function (Container $container): void { $log->addTag('code', $error->getCode()); $log->addTag('verboseType', \get_class($error)); - $log->addTag('projectId', $project->getId() ?? ''); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); diff --git a/app/realtime.php b/app/realtime.php index bd493dc64f..71aa251069 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -38,6 +38,7 @@ use Utopia\DSN\DSN; use Utopia\Logger\Log; use Utopia\Pools\Group; use Utopia\Registry\Registry; +use Utopia\Span\Span; use Utopia\System\System; use Utopia\Telemetry\Adapter\None as NoTelemetry; use Utopia\WebSocket\Adapter; @@ -262,7 +263,9 @@ $stats->create(); $containerId = uniqid(); $statsDocument = null; -$workerNumber = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); + +$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) + ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); $adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80)); $adapter @@ -394,15 +397,27 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::success('Worker ' . $workerId . ' started successfully'); $telemetry = getTelemetry($workerId); + $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; + $workerTelemetryAttributes = ['workerId' => (string) $workerId]; $register->set('telemetry', fn () => $telemetry); + $register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes); + $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers')); + $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients')); + $register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions')); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( name: 'realtime.server.delivery_delay', unit: 'ms', - advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], )); + $register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.arrival_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], + )); + $register->get('telemetry.workerCounter')->add(1); $attempts = 0; $start = time(); @@ -519,12 +534,28 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) { $event = json_decode($payload, true); + $eventTimestamp = $event['data']['timestamp'] ?? null; + if (\is_string($eventTimestamp)) { + try { + $eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC')); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $eventTimestampMs = (float) $eventDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs); + + $register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } + if ($event['permissionsChanged'] && isset($event['userId'])) { $projectId = $event['project']; $userId = $event['userId']; if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) { $connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId])); + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $consoleDatabase = getConsoleDB(); $project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId)); $database = getProjectDB($project); @@ -555,6 +586,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($authorization !== null) { $realtime->connections[$connection]['authorization'] = $authorization; } + + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } } } @@ -600,7 +637,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; if (\is_string($updatedAt)) { try { - $updatedAtDate = new \DateTimeImmutable($updatedAt); + $updatedAtDate = new \DateTimeImmutable($updatedAt, new \DateTimeZone('UTC')); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; $nowTimestampMs = (float) $now->format('U.u') * 1000; @@ -640,6 +677,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); +$server->onWorkerStop(function (int $workerId) use ($register) { + Console::warning('Worker ' . $workerId . ' stopping'); + + try { + $register->get('telemetry.workerCounter')->add(-1); + } catch (\Throwable $th) { + Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage()); + } +}); + $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) { global $container; $request = new Request($request); @@ -655,6 +702,20 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $project = null; $logUser = null; $authorization = null; + $rawSize = $request->getSize(); + $channelCount = 0; + $subscriptionCount = 0; + $outboundBytes = 0; + $responseCode = 200; + $subscriptionMode = 'message'; + $success = false; + + Span::init('realtime.open'); + Span::add('realtime.connectionId', $connection); + Span::add('realtime.inboundBytes', $rawSize); + if (!empty($request->getOrigin())) { + Span::add('realtime.origin', $request->getOrigin()); + } try { /** @var Document $project */ @@ -704,8 +765,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests'); } - $rawSize = $request->getSize(); - triggerStats([ METRIC_REALTIME_INBOUND => $rawSize, ], $project->getId()); @@ -725,9 +784,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $roles = $user->getRoles($authorization); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $channelCount = \count($channels); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); + $register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes')); $register->get('telemetry.connectionCreatedCounter')->add(1); $stats->set($projectId, [ @@ -761,11 +822,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + $subscriptionMode = 'message'; + $success = true; return; } $names = array_keys($channels); + $subscriptionMode = 'url'; try { $subscriptions = Realtime::constructSubscriptions( @@ -792,6 +857,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping[$index] = $subscriptionId; } + $subscriptionCount = \count($subscriptions); + if (!empty($subscriptions)) { + $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes')); + } $realtime->connections[$connection]['authorization'] = $authorization; @@ -807,8 +876,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); - + $success = true; } catch (Throwable $th) { logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization); @@ -818,6 +888,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, if (!\is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -835,7 +906,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); $server->close($connection, $code); if (System::getEnv('_APP_ENV', 'production') === 'development') { @@ -843,16 +916,44 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Console::error('[Error] Code: ' . $response['data']['code']); Console::error('[Error] Message: ' . $response['data']['message']); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionMode', $subscriptionMode); + Span::add('realtime.channelCount', $channelCount); + Span::add('realtime.subscriptionCount', $subscriptionCount); + Span::add('realtime.outboundBytes', $outboundBytes); + if (!empty($project?->getId())) { + Span::add('realtime.projectId', $project->getId()); + } + if (!empty($logUser?->getId())) { + Span::add('realtime.userId', $logUser->getId()); + } + Span::current()?->finish(); } }); -$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { +$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) { $project = null; $authorization = null; + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $rawSize = \strlen($message); + $messageType = 'invalid'; + $subscriptionDelta = 0; + $subscriptionsRequested = 0; + $subscriptionsRemoved = 0; + $outboundBytes = 0; + $responseCode = 200; + $success = false; + + Span::init('realtime.message'); + Span::add('realtime.connectionId', $connection); + Span::add('realtime.inboundBytes', $rawSize); + Span::add('realtime.containerId', $containerId); + try { - $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); - $projectId = $realtime->connections[$connection]['projectId'] ?? null; // Get authorization from connection (stored during onOpen) $authorization = $realtime->connections[$connection]['authorization'] ?? null; @@ -902,6 +1003,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + $messageType = $message['type'] ?? 'invalid'; + + if (!\is_scalar($messageType)) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); + } + // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); @@ -914,6 +1021,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $pongPayloadJson); + $outboundBytes += \strlen($pongPayloadJson); if ($project !== null && !$project->isEmpty()) { $pongOutboundBytes = \strlen($pongPayloadJson); @@ -960,6 +1068,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); $realtime->unsubscribe($connection); @@ -984,6 +1093,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->connections[$connection]['authorization'] = $authorization; } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + $user = $response->output($user, Response::MODEL_ACCOUNT); $authResponsePayloadJson = json_encode([ @@ -996,6 +1111,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $authResponsePayloadJson); + $outboundBytes += \strlen($authResponsePayloadJson); if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1028,6 +1144,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // bulk validation + parsing before subscribing $parsedPayloads = []; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); foreach ($message['data'] as $payload) { if (!\is_array($payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.'); @@ -1056,19 +1173,28 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); } + $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); + $parsedPayloads[] = [ 'subscriptionId' => $subscriptionId, 'channels' => $payload['channels'], + 'convertedChannels' => $convertedChannels, 'queries' => $convertedQueries, ]; } foreach ($parsedPayloads as $parsedPayload) { $subscriptionId = $parsedPayload['subscriptionId']; - $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId)); + $channels = $parsedPayload['convertedChannels']; $queries = $parsedPayload['queries']; $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($parsedPayloads); + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } $responsePayload = json_encode([ 'type' => 'response', @@ -1078,7 +1204,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'subscriptions' => \array_map(function (array $parsedPayload) { return [ 'subscriptionId' => $parsedPayload['subscriptionId'], - 'channels' => $parsedPayload['channels'], + 'channels' => $parsedPayload['convertedChannels'], 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']), ]; }, $parsedPayloads), @@ -1086,6 +1212,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $responsePayload); + $outboundBytes += \strlen($responsePayload); if ($project !== null && !$project->isEmpty()) { $subscribeOutboundBytes = \strlen($responsePayload); @@ -1104,6 +1231,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); + // Validate every payload before executing any removal so an invalid entry // later in the batch does not leave earlier entries half-applied on the server. $validatedIds = []; @@ -1127,6 +1256,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'removed' => $wasRemoved, ]; } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($validatedIds); + $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'])); + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } $unsubscribeResponsePayload = json_encode([ 'type' => 'response', @@ -1138,6 +1274,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $unsubscribeResponsePayload); + $outboundBytes += \strlen($unsubscribeResponsePayload); if ($project !== null && !$project->isEmpty()) { $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); @@ -1154,12 +1291,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } + $success = true; } catch (Throwable $th) { logError($th, 'realtimeMessage', project: $project, authorization: $authorization); $code = $th->getCode(); if (!is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -1176,19 +1315,52 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); if ($th->getCode() === 1008) { $server->close($connection, $th->getCode()); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionDelta', $subscriptionDelta); + Span::add('realtime.subscriptionsRequested', $subscriptionsRequested); + Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved); + Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested); + Span::add('realtime.outboundBytes', $outboundBytes); + Span::add('realtime.projectId', $project?->getId() ?? $projectId); + Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null); + Span::add('realtime.messageType', $messageType); + Span::current()?->finish(); } }); $server->onClose(function (int $connection) use ($realtime, $stats, $register) { + $projectId = null; + $userId = null; + $subscriptionsBeforeClose = 0; + $success = false; + + Span::init('realtime.close'); + Span::add('realtime.connectionId', $connection); + + if (array_key_exists($connection, $realtime->connections)) { + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $userId = $realtime->connections[$connection]['userId'] ?? null; + } + try { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); + $register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes')); + $subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection)); + if ($subscriptionsBeforeClose > 0) { + $register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes')); + } $projectId = $realtime->connections[$connection]['projectId']; @@ -1196,12 +1368,30 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { METRIC_REALTIME_CONNECTIONS => -1, ], $projectId); } + $success = true; } catch (\Throwable $th) { // Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine // backtraces and unsubscribe() below would never run (connection cleanup would fail). Console::error('Realtime onClose error: ' . $th->getMessage()); + Span::error($th); + } finally { + try { + $realtime->unsubscribe($connection); + } catch (\Throwable $th) { + Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage()); + Span::error($th); + } + + Span::add('realtime.success', $success); + if (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + if (!empty($userId)) { + Span::add('realtime.userId', $userId); + } + Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); + Span::current()?->finish(); } - $realtime->unsubscribe($connection); Console::info('Connection close: ' . $connection); }); diff --git a/app/worker.php b/app/worker.php index 7cc34f397c..12b822c4eb 100644 --- a/app/worker.php +++ b/app/worker.php @@ -129,7 +129,7 @@ $worker $log->setAction('appwrite-queue-' . $queueName); $log->addTag('verboseType', get_class($error)); $log->addTag('code', $error->getCode()); - $log->addTag('projectId', $project->getId() ?? 'n/a'); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); $log->addExtra('trace', $error->getTraceAsString()); diff --git a/composer.lock b/composer.lock index d0d69bd0c5..02590020e0 100644 --- a/composer.lock +++ b/composer.lock @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.21", + "version": "5.3.22", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d" + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/ee2d7d4c87b3a3fae954089ad7494ceb454f619d", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d", + "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.21" + "source": "https://github.com/utopia-php/database/tree/5.3.22" }, - "time": "2026-04-10T12:38:57+00:00" + "time": "2026-04-20T07:12:46+00:00" }, { "name": "utopia-php/detector", diff --git a/docker-compose.yml b/docker-compose.yml index 46d0b3cc9a..30e893e287 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -257,7 +257,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:7.8.26 + image: appwrite/console:7.8.45 restart: unless-stopped networks: - appwrite diff --git a/docs/references/account/create-2fa-challenge.md b/docs/references/account/create-2fa-challenge.md deleted file mode 100644 index ee6ef2f2ac..0000000000 --- a/docs/references/account/create-2fa-challenge.md +++ /dev/null @@ -1 +0,0 @@ -Initialize an MFA challenge of the specified factor. The factor must be available on the account. \ No newline at end of file diff --git a/docs/references/account/delete-session-current.md b/docs/references/account/delete-session-current.md deleted file mode 100644 index d38520f479..0000000000 --- a/docs/references/account/delete-session-current.md +++ /dev/null @@ -1 +0,0 @@ -Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/documentsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/documentsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/documentsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/docs/references/functions/create-build.md b/docs/references/functions/create-build.md deleted file mode 100644 index 160a04c291..0000000000 --- a/docs/references/functions/create-build.md +++ /dev/null @@ -1 +0,0 @@ -Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build. \ No newline at end of file diff --git a/docs/references/functions/create-deployment.md b/docs/references/functions/create-deployment.md deleted file mode 100644 index 3bbdbfc848..0000000000 --- a/docs/references/functions/create-deployment.md +++ /dev/null @@ -1,5 +0,0 @@ -Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID. - -This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions). - -Use the "command" param to set the entrypoint used to execute your code. \ No newline at end of file diff --git a/docs/references/functions/create-execution.md b/docs/references/functions/create-execution.md deleted file mode 100644 index 6089c4ff01..0000000000 --- a/docs/references/functions/create-execution.md +++ /dev/null @@ -1 +0,0 @@ -Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously. \ No newline at end of file diff --git a/docs/references/functions/create-function.md b/docs/references/functions/create-function.md deleted file mode 100644 index 1ac9143f45..0000000000 --- a/docs/references/functions/create-function.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API. \ No newline at end of file diff --git a/docs/references/functions/create-variable.md b/docs/references/functions/create-variable.md deleted file mode 100644 index 40fabd75a8..0000000000 --- a/docs/references/functions/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables. \ No newline at end of file diff --git a/docs/references/functions/delete-deployment.md b/docs/references/functions/delete-deployment.md deleted file mode 100644 index 19c74965bd..0000000000 --- a/docs/references/functions/delete-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Delete a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md deleted file mode 100644 index d7cad98ac1..0000000000 --- a/docs/references/functions/delete-execution.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function execution by its unique ID. diff --git a/docs/references/functions/delete-function.md b/docs/references/functions/delete-function.md deleted file mode 100644 index 92835e3c82..0000000000 --- a/docs/references/functions/delete-function.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-variable.md b/docs/references/functions/delete-variable.md deleted file mode 100644 index 9b1326d96f..0000000000 --- a/docs/references/functions/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-deployment-download.md b/docs/references/functions/get-deployment-download.md deleted file mode 100644 index e662ae2733..0000000000 --- a/docs/references/functions/get-deployment-download.md +++ /dev/null @@ -1 +0,0 @@ -Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download. \ No newline at end of file diff --git a/docs/references/functions/get-deployment.md b/docs/references/functions/get-deployment.md deleted file mode 100644 index 6d73976eb1..0000000000 --- a/docs/references/functions/get-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Get a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-execution.md b/docs/references/functions/get-execution.md deleted file mode 100644 index fc38260bdb..0000000000 --- a/docs/references/functions/get-execution.md +++ /dev/null @@ -1 +0,0 @@ -Get a function execution log by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-function-usage.md b/docs/references/functions/get-function-usage.md deleted file mode 100644 index 4498abb05b..0000000000 --- a/docs/references/functions/get-function-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-function.md b/docs/references/functions/get-function.md deleted file mode 100644 index 557ec316ba..0000000000 --- a/docs/references/functions/get-function.md +++ /dev/null @@ -1 +0,0 @@ -Get a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-functions-usage.md b/docs/references/functions/get-functions-usage.md deleted file mode 100644 index 14427d335d..0000000000 --- a/docs/references/functions/get-functions-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-template.md b/docs/references/functions/get-template.md deleted file mode 100644 index ccdcce7352..0000000000 --- a/docs/references/functions/get-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/get-variable.md b/docs/references/functions/get-variable.md deleted file mode 100644 index f0fa853655..0000000000 --- a/docs/references/functions/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/list-deployments.md b/docs/references/functions/list-deployments.md deleted file mode 100644 index 80bbba1bf6..0000000000 --- a/docs/references/functions/list-deployments.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the function's code deployments. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-executions.md b/docs/references/functions/list-executions.md deleted file mode 100644 index 168c795b20..0000000000 --- a/docs/references/functions/list-executions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the current user function execution logs. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-functions.md b/docs/references/functions/list-functions.md deleted file mode 100644 index 9ad432fdc0..0000000000 --- a/docs/references/functions/list-functions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the project's functions. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-runtimes.md b/docs/references/functions/list-runtimes.md deleted file mode 100644 index d4d3d23b18..0000000000 --- a/docs/references/functions/list-runtimes.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all runtimes that are currently active on your instance. \ No newline at end of file diff --git a/docs/references/functions/list-specifications.md b/docs/references/functions/list-specifications.md deleted file mode 100644 index d65a215827..0000000000 --- a/docs/references/functions/list-specifications.md +++ /dev/null @@ -1 +0,0 @@ -List allowed function specifications for this instance. diff --git a/docs/references/functions/list-templates.md b/docs/references/functions/list-templates.md deleted file mode 100644 index ed43b9cbf4..0000000000 --- a/docs/references/functions/list-templates.md +++ /dev/null @@ -1 +0,0 @@ -List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/list-variables.md b/docs/references/functions/list-variables.md deleted file mode 100644 index 68bd5e17e1..0000000000 --- a/docs/references/functions/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all variables of a specific function. \ No newline at end of file diff --git a/docs/references/functions/update-deployment-build.md b/docs/references/functions/update-deployment-build.md deleted file mode 100644 index d047990adf..0000000000 --- a/docs/references/functions/update-deployment-build.md +++ /dev/null @@ -1 +0,0 @@ -Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details. \ No newline at end of file diff --git a/docs/references/functions/update-function-deployment.md b/docs/references/functions/update-function-deployment.md deleted file mode 100644 index 7a85188842..0000000000 --- a/docs/references/functions/update-function-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint. \ No newline at end of file diff --git a/docs/references/functions/update-function.md b/docs/references/functions/update-function.md deleted file mode 100644 index 5a9a84ad94..0000000000 --- a/docs/references/functions/update-function.md +++ /dev/null @@ -1 +0,0 @@ -Update function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/update-variable.md b/docs/references/functions/update-variable.md deleted file mode 100644 index af2c38aea2..0000000000 --- a/docs/references/functions/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update variable by its unique ID. \ No newline at end of file diff --git a/docs/references/health/get-queue-stats-usage-dump.md b/docs/references/health/get-queue-stats-usage-dump.md deleted file mode 100644 index 3c95da1b8a..0000000000 --- a/docs/references/health/get-queue-stats-usage-dump.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue-tasks.md b/docs/references/health/get-queue-tasks.md deleted file mode 100644 index ea6fa22087..0000000000 --- a/docs/references/health/get-queue-tasks.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of tasks that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue.md b/docs/references/health/get-queue.md deleted file mode 100644 index e4558f941f..0000000000 --- a/docs/references/health/get-queue.md +++ /dev/null @@ -1 +0,0 @@ -Check the Appwrite queue messaging servers are up and connection is successful. \ No newline at end of file diff --git a/docs/references/messaging/delete.md b/docs/references/messaging/delete.md deleted file mode 100644 index b07d020900..0000000000 --- a/docs/references/messaging/delete.md +++ /dev/null @@ -1 +0,0 @@ -Delete a message by its unique ID. \ No newline at end of file diff --git a/docs/references/project/create-variable.md b/docs/references/project/create-variable.md deleted file mode 100644 index 2bbee5bf99..0000000000 --- a/docs/references/project/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/delete-variable.md b/docs/references/project/delete-variable.md deleted file mode 100644 index 9be15f83ca..0000000000 --- a/docs/references/project/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/get-variable.md b/docs/references/project/get-variable.md deleted file mode 100644 index 8636768434..0000000000 --- a/docs/references/project/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/list-variables.md b/docs/references/project/list-variables.md deleted file mode 100644 index fbe191178a..0000000000 --- a/docs/references/project/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/update-variable.md b/docs/references/project/update-variable.md deleted file mode 100644 index 603622b2c7..0000000000 --- a/docs/references/project/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/projects/create-key.md b/docs/references/projects/create-key.md deleted file mode 100644 index d6633d936d..0000000000 --- a/docs/references/projects/create-key.md +++ /dev/null @@ -1 +0,0 @@ -Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project. \ No newline at end of file diff --git a/docs/references/projects/create-platform.md b/docs/references/projects/create-platform.md deleted file mode 100644 index b5d8be0ff9..0000000000 --- a/docs/references/projects/create-platform.md +++ /dev/null @@ -1 +0,0 @@ -Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API. \ No newline at end of file diff --git a/docs/references/projects/create-webhook.md b/docs/references/projects/create-webhook.md deleted file mode 100644 index cd0e93332b..0000000000 --- a/docs/references/projects/create-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur. \ No newline at end of file diff --git a/docs/references/projects/delete-key.md b/docs/references/projects/delete-key.md deleted file mode 100644 index 9f3774b419..0000000000 --- a/docs/references/projects/delete-key.md +++ /dev/null @@ -1 +0,0 @@ -Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls. \ No newline at end of file diff --git a/docs/references/projects/delete-platform.md b/docs/references/projects/delete-platform.md deleted file mode 100644 index 7d538cac26..0000000000 --- a/docs/references/projects/delete-platform.md +++ /dev/null @@ -1 +0,0 @@ -Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project. \ No newline at end of file diff --git a/docs/references/projects/delete-webhook.md b/docs/references/projects/delete-webhook.md deleted file mode 100644 index 74fee2bcec..0000000000 --- a/docs/references/projects/delete-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events. \ No newline at end of file diff --git a/docs/references/projects/get-key.md b/docs/references/projects/get-key.md deleted file mode 100644 index bd6351f420..0000000000 --- a/docs/references/projects/get-key.md +++ /dev/null @@ -1 +0,0 @@ -Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes. \ No newline at end of file diff --git a/docs/references/projects/get-platform.md b/docs/references/projects/get-platform.md deleted file mode 100644 index 87129b829d..0000000000 --- a/docs/references/projects/get-platform.md +++ /dev/null @@ -1 +0,0 @@ -Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations. \ No newline at end of file diff --git a/docs/references/projects/get-webhook.md b/docs/references/projects/get-webhook.md deleted file mode 100644 index 559c73c748..0000000000 --- a/docs/references/projects/get-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project. \ No newline at end of file diff --git a/docs/references/projects/list-keys.md b/docs/references/projects/list-keys.md deleted file mode 100644 index a7b701b0d7..0000000000 --- a/docs/references/projects/list-keys.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all API keys from the current project. \ No newline at end of file diff --git a/docs/references/projects/list-platforms.md b/docs/references/projects/list-platforms.md deleted file mode 100644 index ed9ade0852..0000000000 --- a/docs/references/projects/list-platforms.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all platforms in the project. This endpoint returns an array of all platforms and their configurations. \ No newline at end of file diff --git a/docs/references/projects/list-webhooks.md b/docs/references/projects/list-webhooks.md deleted file mode 100644 index bbbf4c7376..0000000000 --- a/docs/references/projects/list-webhooks.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all webhooks belonging to the project. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/projects/update-api-status-all.md b/docs/references/projects/update-api-status-all.md deleted file mode 100644 index 654070759f..0000000000 --- a/docs/references/projects/update-api-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all API types. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime all at once. \ No newline at end of file diff --git a/docs/references/projects/update-api-status.md b/docs/references/projects/update-api-status.md deleted file mode 100644 index af10a0d4f4..0000000000 --- a/docs/references/projects/update-api-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific API type. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime. \ No newline at end of file diff --git a/docs/references/projects/update-auth-duration.md b/docs/references/projects/update-auth-duration.md deleted file mode 100644 index bdc75fa6f0..0000000000 --- a/docs/references/projects/update-auth-duration.md +++ /dev/null @@ -1 +0,0 @@ -Update how long sessions created within a project should stay active for. \ No newline at end of file diff --git a/docs/references/projects/update-auth-limit.md b/docs/references/projects/update-auth-limit.md deleted file mode 100644 index c8faa3fe37..0000000000 --- a/docs/references/projects/update-auth-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of users allowed in this project. Set to 0 for unlimited users. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-dictionary.md b/docs/references/projects/update-auth-password-dictionary.md deleted file mode 100644 index 1d47d30bb5..0000000000 --- a/docs/references/projects/update-auth-password-dictionary.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against common passwords dictionary. This helps ensure users don't use common and insecure passwords. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-history.md b/docs/references/projects/update-auth-password-history.md deleted file mode 100644 index 3a892915d5..0000000000 --- a/docs/references/projects/update-auth-password-history.md +++ /dev/null @@ -1 +0,0 @@ -Update the authentication password history requirement. Use this endpoint to require new passwords to be different than the last X amount of previously used ones. \ No newline at end of file diff --git a/docs/references/projects/update-auth-sessions-limit.md b/docs/references/projects/update-auth-sessions-limit.md deleted file mode 100644 index 7d5fdffae7..0000000000 --- a/docs/references/projects/update-auth-sessions-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of sessions allowed per user within the project, if the limit is hit the oldest session will be deleted to make room for new sessions. \ No newline at end of file diff --git a/docs/references/projects/update-key.md b/docs/references/projects/update-key.md deleted file mode 100644 index 4934a51497..0000000000 --- a/docs/references/projects/update-key.md +++ /dev/null @@ -1 +0,0 @@ -Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key. \ No newline at end of file diff --git a/docs/references/projects/update-memberships-privacy.md b/docs/references/projects/update-memberships-privacy.md deleted file mode 100644 index a1affc1166..0000000000 --- a/docs/references/projects/update-memberships-privacy.md +++ /dev/null @@ -1 +0,0 @@ -Update project membership privacy settings. Use this endpoint to control what user information is visible to other team members, such as user name, email, and MFA status. \ No newline at end of file diff --git a/docs/references/projects/update-personal-data-check.md b/docs/references/projects/update-personal-data-check.md deleted file mode 100644 index 42847fdbfc..0000000000 --- a/docs/references/projects/update-personal-data-check.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against their personal data. This helps prevent users from using personal information in their passwords. \ No newline at end of file diff --git a/docs/references/projects/update-platform.md b/docs/references/projects/update-platform.md deleted file mode 100644 index d04b07bafd..0000000000 --- a/docs/references/projects/update-platform.md +++ /dev/null @@ -1 +0,0 @@ -Update a platform by its unique ID. Use this endpoint to update the platform's name, key, platform store ID, or hostname. \ No newline at end of file diff --git a/docs/references/projects/update-service-status-all.md b/docs/references/projects/update-service-status-all.md deleted file mode 100644 index f05e7d8c5c..0000000000 --- a/docs/references/projects/update-service-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all services. Use this endpoint to enable or disable all optional services at once. \ No newline at end of file diff --git a/docs/references/projects/update-service-status.md b/docs/references/projects/update-service-status.md deleted file mode 100644 index 9d3b0743a8..0000000000 --- a/docs/references/projects/update-service-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific service. Use this endpoint to enable or disable a service in your project. \ No newline at end of file diff --git a/docs/references/projects/update-session-alerts.md b/docs/references/projects/update-session-alerts.md deleted file mode 100644 index 36859e0c1e..0000000000 --- a/docs/references/projects/update-session-alerts.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable session email alerts. When enabled, users will receive email notifications when new sessions are created. \ No newline at end of file diff --git a/docs/references/projects/update-session-invalidation.md b/docs/references/projects/update-session-invalidation.md deleted file mode 100644 index cbaf378624..0000000000 --- a/docs/references/projects/update-session-invalidation.md +++ /dev/null @@ -1 +0,0 @@ -Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/docs/references/projects/update-webhook-signature.md b/docs/references/projects/update-webhook-signature.md deleted file mode 100644 index 8525a05777..0000000000 --- a/docs/references/projects/update-webhook-signature.md +++ /dev/null @@ -1 +0,0 @@ -Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook. \ No newline at end of file diff --git a/docs/references/projects/update-webhook.md b/docs/references/projects/update-webhook.md deleted file mode 100644 index 745e4aebe1..0000000000 --- a/docs/references/projects/update-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook. \ No newline at end of file diff --git a/docs/references/tablesdb/get-database.md b/docs/references/tablesdb/get-database.md deleted file mode 100644 index 24183f6f6b..0000000000 --- a/docs/references/tablesdb/get-database.md +++ /dev/null @@ -1 +0,0 @@ -Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/vectorsdb/decrement-document-attribute.md b/docs/references/vectorsdb/decrement-document-attribute.md deleted file mode 100644 index b7b32d6148..0000000000 --- a/docs/references/vectorsdb/decrement-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection-logs.md b/docs/references/vectorsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/vectorsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-document-logs.md b/docs/references/vectorsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/vectorsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/increment-document-attribute.md b/docs/references/vectorsdb/increment-document-attribute.md deleted file mode 100644 index 7a19b3fbc7..0000000000 --- a/docs/references/vectorsdb/increment-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-attributes.md b/docs/references/vectorsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/vectorsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon index 85d18fd44d..0b8761c19e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 3 + level: 4 tmpDir: .phpstan-cache paths: - src diff --git a/src/Appwrite/Auth/OAuth2/Apple.php b/src/Appwrite/Auth/OAuth2/Apple.php index 0b4ec50881..bae3446fcb 100644 --- a/src/Appwrite/Auth/OAuth2/Apple.php +++ b/src/Appwrite/Auth/OAuth2/Apple.php @@ -165,9 +165,9 @@ class Apple extends OAuth2 protected function getAppSecret(): string { - try { - $secret = \json_decode($this->appSecret, true); - } catch (\Throwable $th) { + $secret = \json_decode($this->appSecret, true); + + if (!\is_array($secret)) { throw new Exception('Invalid secret'); } diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 7ff16fcb78..6e0da14437 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -11,11 +11,6 @@ class Etsy extends OAuth2 */ private string $endpoint = 'https://api.etsy.com/v3/public'; - /** - * @var string - */ - private string $version = '2022-07-14'; - /** * @var array */ diff --git a/src/Appwrite/Auth/OAuth2/Podio.php b/src/Appwrite/Auth/OAuth2/Podio.php index 0b1f35414b..6a977da854 100644 --- a/src/Appwrite/Auth/OAuth2/Podio.php +++ b/src/Appwrite/Auth/OAuth2/Podio.php @@ -121,7 +121,7 @@ class Podio extends OAuth2 { $user = $this->getUser($accessToken); - return \strval($user['user_id']) ?? ''; + return \strval($user['user_id']); } /** diff --git a/src/Appwrite/Auth/OAuth2/Zoom.php b/src/Appwrite/Auth/OAuth2/Zoom.php index 9dad22212a..a4967741a9 100644 --- a/src/Appwrite/Auth/OAuth2/Zoom.php +++ b/src/Appwrite/Auth/OAuth2/Zoom.php @@ -11,11 +11,6 @@ class Zoom extends OAuth2 */ private string $endpoint = 'https://zoom.us'; - /** - * @var string - */ - private string $version = '2022-03-26'; - /** * @var array */ diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php index 3b09839bd1..b047e5dd2f 100644 --- a/src/Appwrite/Auth/Validator/PersonalData.php +++ b/src/Appwrite/Auth/Validator/PersonalData.php @@ -59,7 +59,7 @@ class PersonalData extends Password return false; } - if ($this->email && strpos($password, explode('@', $this->email)[0] ?? '') !== false) { + if ($this->email && strpos($password, explode('@', $this->email)[0]) !== false) { return false; } diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 3d31101d2b..9b3d68519f 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -133,7 +133,8 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '') + ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '') // Includes backwards compatibility + ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '') ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM)) ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); } diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php index 87699aaeba..e7993d6927 100644 --- a/src/Appwrite/Docker/Compose/Service.php +++ b/src/Appwrite/Docker/Compose/Service.php @@ -21,7 +21,7 @@ class Service array_walk($ports, function (&$value, $key) { $split = explode(':', $value); $this->service['ports'][ - (isset($split[0])) ? $split[0] : '' + $split[0] ] = (isset($split[1])) ? $split[1] : ''; }); diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index af5e4f11e2..7e44a6c5cf 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -15,7 +15,7 @@ class Env foreach ($data as &$row) { $row = explode('=', $row, 2); - $key = (isset($row[0])) ? trim($row[0]) : null; + $key = trim($row[0]); $value = (isset($row[1])) ? (function (string $v): string { $v = trim($v); if ( diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index fae2d0e843..357442a07c 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -459,7 +459,7 @@ class Event /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && \str_starts_with($parts[3], '['); $hasSubSubResource = $count > 5 && \str_starts_with($parts[5], '[') && $hasSubResource; diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index d8f25489c6..0685586c60 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -251,14 +251,26 @@ class Mail extends Event } /** - * Set SMTP reply to + * Set SMTP reply-to email * - * @param string $replyTo + * @param string $email * @return self */ - public function setSmtpReplyTo(string $replyTo): self + public function setSmtpReplyToEmail(string $email): self { - $this->smtp['replyTo'] = $replyTo; + $this->smtp['replyToEmail'] = $email; + return $this; + } + + /** + * Set SMTP reply-to name + * + * @param string $name + * @return self + */ + public function setSmtpReplyToName(string $name): self + { + $this->smtp['replyToName'] = $name; return $this; } @@ -333,13 +345,23 @@ class Mail extends Event } /** - * Get SMTP reply to + * Get SMTP reply-to email * * @return string */ - public function getSmtpReplyTo(): string + public function getSmtpReplyToEmail(): string { - return $this->smtp['replyTo'] ?? ''; + return $this->smtp['replyToEmail'] ?? ''; + } + + /** + * Get SMTP reply-to name + * + * @return string + */ + public function getSmtpReplyToName(): string + { + return $this->smtp['replyToName'] ?? ''; } /** diff --git a/src/Appwrite/Event/Validator/Event.php b/src/Appwrite/Event/Validator/Event.php index a3605e4df5..7a4f4fbcf8 100644 --- a/src/Appwrite/Event/Validator/Event.php +++ b/src/Appwrite/Event/Validator/Event.php @@ -44,7 +44,7 @@ class Event extends Validator /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && ($events[$type]['$resource'] ?? false) && ($events[$type][$parts[2]]['$resource'] ?? false); $hasSubSubResource = $count > 5 && $hasSubResource && ($events[$type][$parts[2]][$parts[4]]['$resource'] ?? false); @@ -61,9 +61,6 @@ class Event extends Validator if ($hasSubSubResource) { $subSubType = $parts[4]; $subSubResource = $parts[5]; - if ($count === 8) { - $attribute = $parts[7]; - } } if ($hasSubResource && !$hasSubSubResource) { diff --git a/src/Appwrite/Event/Webhook.php b/src/Appwrite/Event/Webhook.php index f6d16c8b14..5cd773a18f 100644 --- a/src/Appwrite/Event/Webhook.php +++ b/src/Appwrite/Event/Webhook.php @@ -24,7 +24,7 @@ class Webhook extends Event public function trimPayload(): array { $trimmed = parent::trimPayload(); - if (isset($this->context)) { + if (!empty($this->context)) { $trimmed['context'] = []; } return $trimmed; diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index 53474b855a..55810fd74e 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -91,26 +91,20 @@ class Mapper } } - $responses = $method->getResponses() ?? []; + $responses = $method->getResponses(); - // If responses is an array, map each response to its model - if (\is_array($responses)) { - $models = []; - foreach ($responses as $response) { - $modelName = $response->getModel(); + // Map each response to its model + $models = []; + foreach ($responses as $response) { + $modelName = $response->getModel(); - if (\is_array($modelName)) { - foreach ($modelName as $name) { - $models[] = self::$models[$name]; - } - } else { - $models[] = self::$models[$modelName]; + if (\is_array($modelName)) { + foreach ($modelName as $name) { + $models[] = self::$models[$name]; } + } else { + $models[] = self::$models[$modelName]; } - } else { - // If single response, get its model and wrap in array - $modelName = $responses->getModel(); - $models = [self::$models[$modelName]]; } foreach ($models as $model) { diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index eeb1387674..8fe7342ec2 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -452,6 +452,7 @@ class Realtime extends MessagingAdapter * Reserved channel params with expected type * If matched the expected type then skip the query parsing like in project */ + /** @var array $reservedParamExpectedTypes */ $reservedParamExpectedTypes = [ 'project' => 'string', ]; @@ -465,7 +466,6 @@ class Realtime extends MessagingAdapter $isExpectedType = match ($expectedType) { 'array' => \is_array($params), 'string' => \is_string($params), - default => false, }; // If the value matches the expected type dont use it the queries diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php index 3297206ccd..862ab7f26c 100644 --- a/src/Appwrite/Migration/Version/V17.php +++ b/src/Appwrite/Migration/Version/V17.php @@ -262,7 +262,7 @@ class V17 extends Migration * Set default maxSessions */ $document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [ - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT + 'maxSessions' => 10 ])); break; case 'users': diff --git a/src/Appwrite/OpenSSL/OpenSSL.php b/src/Appwrite/OpenSSL/OpenSSL.php index 787feb0904..89c52f069e 100644 --- a/src/Appwrite/OpenSSL/OpenSSL.php +++ b/src/Appwrite/OpenSSL/OpenSSL.php @@ -16,7 +16,7 @@ class OpenSSL * @param string $aad * @param int $tag_length * - * @return string + * @return string|false */ public static function encrypt($data, $method, $key, $options = 0, $iv = '', ?string &$tag = null, $aad = '', $tag_length = 16) { diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 8aaaf621bb..e7e9008e3b 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -240,9 +240,7 @@ class Install extends Action $inputValue = trim($inputValue); } if ($storedValue !== $inputValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -262,16 +260,12 @@ class Install extends Action $incomingHash = $state->hashSensitiveValue($incomingValue); if (isset($stored[$hashField])) { if (!hash_equals((string) $stored[$hashField], $incomingHash)) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } } elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -430,7 +424,7 @@ class Install extends Action private function deriveNameFromEmail(string $email): string { $parts = explode('@', $email); - $username = $parts[0] ?? ''; + $username = $parts[0]; $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username); return ucfirst($cleaned); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php index d6ffa64c8f..204ace077c 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -45,7 +45,7 @@ class Status extends Action } $data = $state->readProgressFile($installId); - if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) { + if (isset($data['payload']) && is_array($data['payload'])) { unset( $data['payload']['opensslKey'], $data['payload']['assistantOpenAIKey'], @@ -54,7 +54,7 @@ class Status extends Action ); } // Strip sensitive data from step details - if (is_array($data) && isset($data['details']) && is_array($data['details'])) { + if (isset($data['details']) && is_array($data['details'])) { foreach ($data['details'] as $stepKey => &$stepDetails) { if (is_array($stepDetails)) { unset($stepDetails['sessionSecret'], $stepDetails['trace']); diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php index 99db12dfed..6142e47152 100644 --- a/src/Appwrite/Platform/Installer/Runtime/Config.php +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -218,7 +218,7 @@ final class Config } /** - * @param string[] $value + * @param array $value */ public function setEnabledDatabases(array $value): void { diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php index 75efd7027c..3cbcc51fa6 100644 --- a/src/Appwrite/Platform/Installer/Runtime/State.php +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -19,13 +19,11 @@ class State private const int PORT_MIN = 1; private const int PORT_MAX = 65535; - private array $paths; private bool $bootstrapped = false; private int $lastStaleLockClearAt = 0; - public function __construct(array $paths) + public function __construct() { - $this->paths = $paths; } public function buildConfig(array $overrides = [], bool $useEnv = true): Config @@ -180,7 +178,7 @@ class State if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 99ec9e65d2..38d61b7d24 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -60,7 +60,7 @@ class Server { $this->initPaths(); - $this->state = new State($this->paths); + $this->state = new State(); if (PHP_SAPI === 'cli') { $this->runCli(); diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php index f631015654..5d18b5214a 100644 --- a/src/Appwrite/Platform/Installer/Validator/AppDomain.php +++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php @@ -47,7 +47,7 @@ class AppDomain extends Validator if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php index 754255be15..5765c5bf6e 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php @@ -37,8 +37,8 @@ class Delete extends Action ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'account') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{user.$id}') + ->label('audits.userId', '{user.$id}') ->label('sdk', [ new Method( namespace: 'account', diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 14dc4e3237..7bcc78e974 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -250,7 +250,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -259,8 +260,13 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -277,8 +283,13 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -286,7 +297,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php index f8e7a35b05..d0c600192b 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php @@ -86,10 +86,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php index 37776a3466..ad74d6c192 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php @@ -90,10 +90,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index b6cc408dde..a41d0f81da 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -98,7 +98,7 @@ class Get extends Action $doc->strictErrorChecking = false; @$doc->loadHTML($res->getBody()); - $links = $doc->getElementsByTagName('link') ?? []; + $links = $doc->getElementsByTagName('link'); $outputHref = ''; $outputExt = ''; $space = 0; @@ -128,7 +128,7 @@ class Get extends Action case 'jpeg': $size = \explode('x', \strtolower($sizes)); - $sizeWidth = (int) ($size[0] ?? 0); + $sizeWidth = (int) $size[0]; $sizeHeight = (int) ($size[1] ?? 0); if (($sizeWidth * $sizeHeight) >= $space) { diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php index 27fd8708d9..f3448f5264 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php @@ -60,7 +60,6 @@ class Get extends Action public function action(string $text, int $size, int $margin, bool $download, Response $response) { - $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true); $options = new QROptions([ 'addQuietzone' => true, 'quietzoneSize' => $margin, diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php index 2df12b17d1..c43c0fc4bf 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -105,7 +105,7 @@ class Get extends Action $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); // Convert indexed array to empty array (should not happen due to Assoc validator) - if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { + if (count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { $headers = []; } diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index f388e46f83..85dfec3cfd 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -68,7 +68,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $function->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -169,7 +169,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $site->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index 4afab449c0..1f730fa543 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -12,7 +12,7 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; + private string $context = COLLECTIONS; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php index 0d562a2894..1606c7ab40 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php @@ -26,9 +26,9 @@ use Utopia\Validator\Range; abstract class Action extends UtopiaAction { /** - * @var string|null The current context (either 'column' or 'attribute') + * @var string The current context (either 'column' or 'attribute') */ - private ?string $context = ATTRIBUTES; + private string $context = ATTRIBUTES; /** * Get the correct response model. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 91dd9c603c..8100a2c51b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -14,10 +14,10 @@ use Utopia\Database\Validator\Authorization; abstract class Action extends DatabasesAction { /** - * @var string|null The current context (either 'row' or 'document') + * @var string The current context (either 'row' or 'document') */ - private ?string $context = DOCUMENTS; - private ?string $databaseType = DATABASE_TYPE_LEGACY; + private string $context = DOCUMENTS; + private string $databaseType = DATABASE_TYPE_LEGACY; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 24cba578a9..633a2bbc86 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -293,16 +293,6 @@ class Create extends Action throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); } - if ($permission === Database::PERMISSION_UPDATE) { - $validDocument = $authorization->isValid( - new Input($permission, $document->getUpdate()) - ); - $valid = $validCollection || $validDocument; - if ($documentSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); - } - } - $relationships = \array_filter( $collection->getAttribute('attributes', []), fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index b48df136ee..06f0e9cf1c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -100,7 +100,7 @@ class Get extends Action } try { - $selects = Query::groupByType($queries)['selections'] ?? []; + $selects = Query::groupByType($queries)['selections']; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index ef89b80e97..fb3d414097 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -353,12 +353,7 @@ class Upsert extends Action $collectionsCache = []; if (empty($upserted[0])) { - if ($transactionId !== null) { - // For transactions, get the document with transaction changes applied - $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); - } else { - $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); - } + $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); } $document = $upserted[0]; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index aeee280615..3a49d6c665 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -22,6 +22,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -80,10 +81,11 @@ class XList extends Action ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void { $isAPIKey = $user->isApp($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); @@ -126,8 +128,10 @@ class XList extends Action $cursor->setValue($cursorDocument); } + $dbStart = \microtime(true); + try { - $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); + $hasSelects = ! empty(Query::groupByType($queries)['selections']); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. @@ -178,7 +182,7 @@ class XList extends Action $cachedTotal = null; } if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; + $total = (int) $cachedTotal; } else { $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); try { @@ -206,6 +210,8 @@ class XList extends Action throw new Exception(Exception::DATABASE_TIMEOUT); } + $dbDurationMs = (\microtime(true) - $dbStart) * 1000; + $operations = 0; $collectionsCache = []; foreach ($documents as $document) { @@ -229,5 +235,20 @@ class XList extends Action // rows or documents $this->getSDKGroup() => $documents, ]), $this->getResponseModel()); + + try { + $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); + } catch (\Throwable) { + // Observers must never break the response. + } + } + + /** + * After query hook. + * + * @param array $queries + */ + protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void + { } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php index 400d716e41..251e493cb6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php @@ -10,7 +10,7 @@ abstract class Action extends UtopiaAction /** * The current API context (either 'columnIndex' or 'index'). */ - private ?string $context = INDEX; + private string $context = INDEX; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php index 37213f1061..bea367af36 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php @@ -119,6 +119,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php index 01c00d2fae..85b5c2cc91 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php @@ -100,9 +100,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index 91bc1a3ccf..ccf9632fef 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -9,8 +9,8 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; - private ?string $databaseType = LEGACY; + private string $context = COLLECTIONS; + private string $databaseType = LEGACY; public function getDatabaseType(): string { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php index 18e6fd7a8b..240e7d400c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php @@ -144,6 +144,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php index b8cb774a3e..db73954e7f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php @@ -133,6 +133,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php index 9e0d0b10d9..51c0d67e8a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php @@ -63,6 +63,7 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php index 6b5d5bc1c5..53c59b26d9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php @@ -94,9 +94,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 91c62aea05..87e276719e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -65,6 +65,7 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php index d9b378774b..8a7137e38b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php @@ -98,7 +98,7 @@ class Create extends CreateDocumentAction $error = ''; try { $embedResult = $embeddingAgent->embed($text); - $embedding = $embedResult['embedding'] ?? []; + $embedding = $embedResult['embedding']; $totalDuration += $embedResult['totalDuration'] ?? 0; $totalTokens += $embedResult['tokensProcessed'] ?? 0; } catch (\Exception $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index a50e8f8bdf..39902aea53 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -54,7 +54,7 @@ class Databases extends Action */ public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 65b6ffd5bb..11736c8ca5 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -206,6 +206,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php index fef0708931..e8e9ea9a18 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 841db0e4d3..6e9b178973 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -145,21 +145,8 @@ class Create extends Base } } - /** - * @var array $headers - */ - $assocParams = ['headers']; - foreach ($assocParams as $assocParam) { - if (!empty('headers') && !is_array($$assocParam)) { - $$assocParam = \json_decode($$assocParam, true); - } - } - - $booleanParams = ['async']; - foreach ($booleanParams as $booleamParam) { - if (!empty($$booleamParam) && !is_bool($$booleamParam)) { - $$booleamParam = $$booleamParam === "true" ? true : false; - } + if (!is_array($headers)) { + $headers = \json_decode($headers, true); } // 'headers' validator @@ -366,10 +353,10 @@ class Create extends Base // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 8d4ad5d403..7b294f3f90 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -375,7 +375,7 @@ class Create extends Base } $functionsDomain = $platform['functionsDomain']; - if (!empty($functionsDomain)) { + if (!empty($functionsDomain) && isset($deployment) && !$deployment->isEmpty()) { $routeSubdomain = ID::unique(); $domain = "{$routeSubdomain}.{$functionsDomain}"; // TODO: (@Meldiron) Remove after 1.7.x migration @@ -391,8 +391,8 @@ class Create extends Base 'status' => 'verified', 'type' => 'deployment', 'trigger' => 'manual', - 'deploymentId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getId(), - 'deploymentInternalId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getSequence(), + 'deploymentId' => $deployment->getId(), + 'deploymentInternalId' => $deployment->getSequence(), 'deploymentResourceType' => 'function', 'deploymentResourceId' => $function->getId(), 'deploymentResourceInternalId' => $function->getSequence(), diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 71fc99a30e..7d6572d336 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -162,10 +162,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - if (empty($runtime)) { $runtime = $function->getAttribute('runtime'); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php index 19476329bf..7016d600cb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php @@ -112,6 +112,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php index 38a95d4469..70b7b8e058 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Functions\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -104,6 +105,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php index 5648596826..f6d77c2a0d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php @@ -77,11 +77,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php index 19c345fbc2..13ce73e751 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php index acb066ca9c..54d7a647a3 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php @@ -85,7 +85,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 87e936a965..286f1c55ee 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -102,7 +102,7 @@ class Builds extends Action ): void { Console::log('Build action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -206,7 +206,7 @@ class Builds extends Action throw new \Exception('Resource not found'); } - if ($isResourceBlocked($project, $resourceKey === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { + if ($isResourceBlocked($project, $resource->getCollection() === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { throw new \Exception('Resource is blocked'); } @@ -226,10 +226,6 @@ class Builds extends Action $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; - if ($resource->getCollection() === 'functions' && \is_null($runtime)) { - throw new \Exception('Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); - } - // Realtime preparation $event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update"; $queueForRealtime @@ -829,7 +825,8 @@ class Builds extends Action Console::log('Runtime creation finished'); - if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { + $latestDeployment = $dbForProject->getDocument('deployments', $deploymentId); + if ($latestDeployment->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); return; @@ -1259,21 +1256,6 @@ class Builds extends Action */ protected function afterBuildSuccess(Realtime $queueForRealtime, Database $dbForProject, Document &$deployment, array $runtime, ?string $adapter): void { - if (! ($queueForRealtime instanceof Realtime)) { - throw new Exception('queueForRealtime must be an instance of Realtime'); - } - if (! ($dbForProject instanceof Database)) { - throw new Exception('dbForProject must be an instance of Database'); - } - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } - if (! is_array($runtime)) { - throw new Exception('runtime must be an array'); - } - if (! is_string($adapter) && ! is_null($adapter)) { - throw new Exception('adapter must be a string or null'); - } } /** @@ -1283,13 +1265,6 @@ class Builds extends Action Document $project, Document $deployment, ): void { - if (! ($project instanceof Document)) { - throw new Exception('project must be an instance of Document'); - } - - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } } protected function getRuntime(Document $resource, string $version): array @@ -1313,6 +1288,7 @@ class Builds extends Action return match ($resource->getCollection()) { 'functions' => $resource->getAttribute('version', 'v2'), 'sites' => 'v5', + default => throw new \Exception('Unsupported resource type "' . $resource->getCollection() . '".'), }; } @@ -1445,11 +1421,10 @@ class Builds extends Action ]); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $previewUrl = match ($resource->getCollection()) { - 'functions' => '', - 'sites' => !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', - default => throw new \Exception('Invalid resource type') - }; + $previewUrl = ''; + if ($resource->getCollection() === 'sites' && !$rule->isEmpty()) { + $previewUrl = "{$protocol}://" . $rule->getAttribute('domain', ''); + } $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 423bf0bd41..a6f1ca1b03 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -20,6 +20,8 @@ use Utopia\Platform\Action; use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\System\System; +use Utopia\Telemetry\Adapter as Telemetry; +use Utopia\Telemetry\Counter; use function Swoole\Coroutine\batch; @@ -44,6 +46,7 @@ class Screenshots extends Action ->inject('dbForProject') ->inject('project') ->inject('deviceForFiles') + ->inject('telemetry') ->callback($this->action(...)); } @@ -53,17 +56,19 @@ class Screenshots extends Action Database $dbForPlatform, Database $dbForProject, Document $project, - Device $deviceForFiles + Device $deviceForFiles, + Telemetry $telemetry ): void { Console::log('Screenshot action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); } $screenshotMessage = Screenshot::fromArray($payload); + $counter = $telemetry->createCounter('worker.screenshots.capture'); Console::log('Site screenshot started'); @@ -162,7 +167,7 @@ class Screenshots extends Action try { $config = $configs[$key]; - $config['headers'] = \array_merge($config['headers'] ?? [], [ + $config['headers'] = \array_merge($config['headers'], [ 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey ]); $config['sleep'] = 3000; @@ -268,8 +273,24 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); + $this->recordTelemetry($counter, 'failure'); + throw $th; } + + $this->recordTelemetry($counter, 'success'); + } + + protected function recordTelemetry(Counter $counter, string $result): void + { + try { + $counter->add(1, [ + 'resourceType' => RESOURCE_TYPE_SITES, + 'result' => $result, + ]); + } catch (\Throwable) { + // Telemetry should never affect screenshot processing. + } } protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs) diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index 60cf5d00d4..728ffb8b71 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -82,7 +82,7 @@ class Get extends Action } $certificatePayload = @openssl_x509_parse($peerCertificate); - if ($certificatePayload === false || !\is_array($certificatePayload)) { + if ($certificatePayload === false) { throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to parse peer certificate for ' . $domain); } diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 6d77cc6e16..7602de45d3 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -16,6 +16,7 @@ use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Webhook; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -123,6 +124,7 @@ class Get extends Base System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name), }; $failed = $queue->getSize(failed: true); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 24d1c48cf1..8a3506eb13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Text; @@ -31,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'labels.*.update') + // ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( @@ -53,6 +54,7 @@ class Update extends Action ->inject('response') ->inject('dbForPlatform') ->inject('project') + ->inject('authorization') ->callback($this->action(...)); } @@ -63,11 +65,12 @@ class Update extends Action array $labels, Response $response, Database $dbForPlatform, - Document $project + Document $project, + Authorization $authorization ): void { $labels = (array) \array_values(\array_unique($labels)); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]))); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 4b58766751..24669b02b2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -36,7 +36,7 @@ class Delete extends Action ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') - ->label('audits.resource', 'project.platform/{response.$id}') + ->label('audits.resource', 'project.platform/{request.platformId}') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 2fca0ace6c..6c07727150 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -139,7 +139,7 @@ class Create extends Action if (empty($key) && empty($type)) { // Modern request, validate hostname if (empty($hostname)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "hostname" is not optional.'); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php new file mode 100644 index 0000000000..c947ff225a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -0,0 +1,108 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/membership-privacy') + ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy') + ->desc('Update membership privacy policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateMembershipPrivacyPolicy', + description: <<param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.', optional: true) + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.', optional: true) + ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.', optional: true) + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.', optional: true) + ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?bool $userId, + ?bool $userEmail, + ?bool $userPhone, + ?bool $userName, + ?bool $userMFA, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if ($userId !== null) { + $auths['membershipsUserId'] = $userId; + } + if ($userEmail !== null) { + $auths['membershipsUserEmail'] = $userEmail; + } + if ($userPhone !== null) { + $auths['membershipsUserPhone'] = $userPhone; + } + if ($userName !== null) { + $auths['membershipsUserName'] = $userName; + } + if ($userMFA !== null) { + $auths['membershipsMfa'] = $userMFA; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'membership-privacy'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php new file mode 100644 index 0000000000..e2c678abb6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-dictionary') + ->httpAlias('/v1/projects/:projectId/auth/password-dictionary') + ->desc('Update password dictionary policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordDictionaryPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password dictionary policy. Set to true if you want password change to block passwords in the dictionary, or false to allow them. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['passwordDictionary'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-dictionary'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php new file mode 100644 index 0000000000..a8ae81caff --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-history') + ->httpAlias('/v1/projects/:projectId/auth/password-history') + ->desc('Update password history policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordHistoryPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['passwordHistory'] = 0; + } else { + $auths['passwordHistory'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-history'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php new file mode 100644 index 0000000000..9db7cf0549 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-personal-data') + ->httpAlias('/v1/projects/:projectId/auth/personal-data') + ->desc('Update password personal data policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordPersonalDataPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['personalDataCheck'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-personal-data'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php new file mode 100644 index 0000000000..22b7a44b04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-alert') + ->httpAlias('/v1/projects/:projectId/auth/session-alerts') + ->desc('Update session alert policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionAlertPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session alert policy. Set to true if you want users to receive email notifications when a sessions are created for their users, or false to not send email alerts.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['sessionAlerts'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-alert'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php new file mode 100644 index 0000000000..ba72c93a6f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-duration') + ->httpAlias('/v1/projects/:projectId/auth/duration') + ->desc('Update session duration policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionDurationPolicy', + description: <<param('duration', null, new Range(5, 31536000), 'Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + int $duration, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['duration'] = $duration; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-duration'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php new file mode 100644 index 0000000000..8f8a959959 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-invalidation') + ->httpAlias('/v1/projects/:projectId/auth/session-invalidation') + ->desc('Update session invalidation policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionInvalidationPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session invalidation policy. Set to true if you want password change to invalidate all sessions of an user, or false to keep sessions active.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['invalidateSessions'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-invalidation'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php new file mode 100644 index 0000000000..382ed6f0d9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-limit') + ->httpAlias('/v1/projects/:projectId/auth/max-sessions') + ->desc('Update session limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['maxSessions'] = 0; + } else { + $auths['maxSessions'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-limit'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php new file mode 100644 index 0000000000..9129b81250 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/user-limit') + ->httpAlias('/v1/projects/:projectId/auth/limit') + ->desc('Update user limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateUserLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['limit'] = 0; + } else { + $auths['limit'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'user-limit'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php new file mode 100644 index 0000000000..7095c2d2d0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -0,0 +1,177 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/smtp/tests') + ->httpAlias('/v1/projects/:projectId/smtp/tests') + ->desc('Create project SMTP test') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'createSMTPTest', + description: <<param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') + ->param('senderName', '', new Text(256), 'Name of the email sender', optional: true, deprecated: true) // Backwards compatibility + ->param('senderEmail', '', new Email(), 'Email of the sender', optional: true, deprecated: true) // Backwards compatibility + ->param('replyTo', '', new Email(), 'Reply to email', optional: true, deprecated: true) // Backwards compatibility + ->param('host', '', new Hostname(), 'SMTP server host name', optional: true, deprecated: true) // Backwards compatibility + ->param('port', null, new Integer(), 'SMTP server port', optional: true, deprecated: true) // Backwards compatibility + ->param('username', '', new Text(256), 'SMTP server username', optional: true, deprecated: true) // Backwards compatibility + ->param('password', '', new Text(256), 'SMTP server password', optional: true, deprecated: true) // Backwards compatibility + ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility + ->inject('response') + ->inject('project') + ->inject('queueForMails') + ->inject('plan') + ->callback($this->action(...)); + } + + /** + * @param array $emails + */ + public function action( + array $emails, + string $paramSenderName, // Backwards compatibility + string $paramSenderEmail, // Backwards compatibility + string $paramReplyTo, // Backwards compatibility + string $paramHost, // Backwards compatibility + ?int $paramPort, // Backwards compatibility + string $paramUsername, // Backwards compatibility + string $paramPassword, // Backwards compatibility + string $paramSecure, // Backwards compatibility + Response $response, + Document $project, + Mail $queueForMails, + array $plan + ): void { + // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config. + // When inline params are provided they are treated as self-contained — project config is ignored + // so legacy (1.9.1) callers do not get project state (e.g. replyToName) leaked into their request. + $hasInlineParams = !empty($paramHost); + + $smtp = $project->getAttribute('smtp', []); + + if (!$hasInlineParams && ($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); + } + + if ($hasInlineParams) { + $senderName = $paramSenderName; + $senderEmail = $paramSenderEmail; + $replyToEmail = $paramReplyTo; + $replyToName = ''; // 1.9.1 inline params did not include replyToName + $host = $paramHost; + $port = $paramPort ?? 0; + $username = $paramUsername; + $password = $paramPassword; + $secure = $paramSecure; + } else { + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Includes backwards compatibility + $replyToName = $smtp['replyToName'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? 0; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + } + + if (empty($senderEmail)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); + } + + if (empty($host)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP host must be configured on the project to send a test email.'); + } + + if (empty($port)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); + } + + // Fallback to sender details when reply-to is not explicitly configured + $replyToEmailDisplay = !empty($replyToEmail) ? $replyToEmail : $senderEmail; + $replyToNameDisplay = !empty($replyToName) ? $replyToName : $senderName; + + $subject = 'Custom SMTP email sample'; + $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); + $template + ->setParam('{{from}}', "{$senderName} ({$senderEmail})") + ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})") + ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) + ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) + ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) + ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) + ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) + ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) + ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); + + foreach ($emails as $email) { + $queueForMails + ->setSmtpHost($host) + ->setSmtpPort($port) + ->setSmtpUsername($username) + ->setSmtpPassword($password) + ->setSmtpSecure($secure) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName) + ->setRecipient($email) + ->setName('') + ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl') + ->setBody($template->render()) + ->setVariables([]) + ->setSubject($subject) + ->trigger(); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php new file mode 100644 index 0000000000..97e723f52c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,173 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP configuration') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + // ->label('event', 'project.smtp.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTP', + description: <<param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', null, new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + ?string $host, + ?int $port, + ?string $username, + ?string $password, + ?string $senderEmail, + ?string $senderName, + ?string $replyToEmail, + ?string $replyToName, + ?string $secure, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Fetch current configuration + $smtp = $project->getAttribute('smtp', []); + + // Apply changes + $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $smtp[$key] = ${$key}; + } + } + + // Backwards compatibility + $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + + if (($smtp['enabled'] ?? false) === true) { + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + } + + // Validate SMTP credentials + // Validate when the caller is explicitly enabling or hasn't expressed a preference + // (so a credentials-only PATCH can auto-enable). Skip only when the caller is + // explicitly keeping/turning SMTP off. + if (\is_null($enabled) || $enabled === true) { + $mail = new PHPMailer(true); + $mail->isSMTP(); + + $mail->Host = $smtp['host'] ?? ''; + $mail->Port = $smtp['port'] ?? ''; + $mail->SMTPSecure = $smtp['secure'] ?? ''; + $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? ''); + + if (!empty($smtp['username'] ?? '')) { + $mail->SMTPAuth = true; + $mail->Username = $smtp['username']; + $mail->Password = $smtp['password'] ?? ''; + } + + if (!empty($smtp['replyToEmail'] ?? '')) { + $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? ''); + } + + $mail->SMTPAutoTLS = false; + $mail->Timeout = 5; + + try { + $valid = $mail->SmtpConnect(); + + if (!$valid) { + throw new \Exception('Connection is not valid.'); + } + + // Auto-enable if configuration is valid + // Dont do this if specifically request to mark disabled + if (\is_null($enabled)) { + $smtp['enabled'] = true; + } + } catch (Throwable $error) { + if (($smtp['enabled'] ?? null) === true) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } + } + } + + // Save configuration + $updates = new Document([ + 'smtp' => $smtp, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php new file mode 100644 index 0000000000..02ba431775 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email/:templateId') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') + ->desc('Get project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + Response $response, + Document $project, + ) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + // Get custom template if available + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + + // Enforced params + $template['templateId'] = $templateId; + $template['locale'] = $locale; + + // Prepare default tempaltes + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $defaultSubject = $localeObj->getText('emails.' . $templateId . '.subject'); + $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); + + // Apply defaults if needed + if (\is_null($template['message'] ?? null)) { + $template['message'] = $defaultMessage; + } + + if (\is_null($template['subject'] ?? null)) { + $template['subject'] = $defaultSubject; + } + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); + } + + protected function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + $message = $message->render(useContent: true); + + return $message; + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php new file mode 100644 index 0000000000..ef93abf683 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -0,0 +1,144 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') + ->desc('Update project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateId].update') + ->label('audits.event', 'project.template.update') + ->label('audits.resource', 'project.template/{response.templateId}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'updateEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true) + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true) + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + ?string $subject, + ?string $message, + ?string $senderName, + ?string $senderEmail, + ?string $replyToEmail, + ?string $replyToName, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + // Prevent template update if custom SMTP is not configured + $smtp = $project->getAttribute('smtp', []); + if (($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); + } + + // Fetch current configuration + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + + // Apply changes + $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $template[$key] = ${$key}; + } + } + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + // Ensure required fields are set + $requiredKeys = ['subject', 'message']; + foreach ($requiredKeys as $key) { + if (empty($template[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + + // Save configuration + $templates['email.' . $templateId . '-' . $locale] = $template; + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateId', $templateId); + + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $template['subject'], + 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', + ]), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..331ad9482e 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,8 +22,21 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData\Update as UpdatePasswordPersonalDataPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert\Update as UpdateSessionAlertPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Update as UpdateSessionDurationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -45,6 +58,14 @@ class Http extends Service $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); + // SMTP + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + + // Templates + $this->addAction(GetTemplate::getName(), new GetTemplate()); + $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); @@ -73,5 +94,16 @@ class Http extends Service $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform()); $this->addAction(GetPlatform::getName(), new GetPlatform()); $this->addAction(ListPlatforms::getName(), new ListPlatforms()); + + // Policies + $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); + $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); + $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); + $this->addAction(UpdatePasswordPersonalDataPolicy::getName(), new UpdatePasswordPersonalDataPolicy()); + $this->addAction(UpdateSessionAlertPolicy::getName(), new UpdateSessionAlertPolicy()); + $this->addAction(UpdateSessionDurationPolicy::getName(), new UpdateSessionDurationPolicy()); + $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy()); + $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy()); + $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy()); } } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php index 5329585be3..76df8c2b45 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php @@ -63,7 +63,7 @@ class Delete extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php index 5cb3b0545f..ff4e348c8e 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php @@ -63,7 +63,7 @@ class Get extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php index f3e47f80ba..9704740bc4 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php @@ -66,7 +66,7 @@ class Update extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index c509a565cd..363c99dc1f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -107,7 +107,7 @@ class Create extends Action $auth = Config::getParam('auth', []); $auths = [ 'limit' => 0, - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'maxSessions' => 0, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, @@ -120,6 +120,8 @@ class Create extends Action 'membershipsUserName' => false, 'membershipsUserEmail' => false, 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, 'invalidateSessions' => true ]; diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 8e420e87f2..0d2a951388 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -109,7 +109,7 @@ class XList extends Action } try { - $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $selectQueries = Query::groupByType($queries)['selections']; $filterQueries = Query::groupByType($queries)['filters']; $projects = $this->find($dbForPlatform, $queries, $selectQueries); diff --git a/src/Appwrite/Platform/Modules/Proxy/Action.php b/src/Appwrite/Platform/Modules/Proxy/Action.php index 30ad140530..8baf54c790 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Action.php +++ b/src/Appwrite/Platform/Modules/Proxy/Action.php @@ -164,9 +164,7 @@ class Action extends PlatformAction $validator = new AnyOf($cnameValidators); $validators[] = $validator; - if (\is_null($mainValidator)) { - $mainValidator = $validator; - } + $mainValidator = $validator; } // Ensure at least one of CNAME/A/AAAA record points to our servers properly diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 8a265ba5bb..5964a20772 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -84,7 +84,8 @@ class Create extends Action $collection = match ($resourceType) { 'site' => 'sites', - 'function' => 'functions' + 'function' => 'functions', + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType), }; $resource = $dbForProject->getDocument($collection, $resourceId); if ($resource->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 8a6964209f..0b8ca24aaa 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -208,6 +208,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php index a9198f937b..3dccd687ea 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index dd9bedffb5..3c0d090b7b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -164,10 +164,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($site->isEmpty()) { - throw new Exception(Exception::SITE_NOT_FOUND); - } - if (empty($framework)) { $framework = $site->getAttribute('framework'); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a6768462d1..85968c7550 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -121,6 +121,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index a90cb0cab9..636889f6c0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Sites\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -107,6 +108,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php index 703806f1aa..d61c9892cf 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php @@ -67,11 +67,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php index 54522c0ec7..2fcb051996 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php index 99f68a45df..08cdd4ac38 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php @@ -79,7 +79,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index c5f4f3dccd..befc02a1df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -384,14 +384,11 @@ class Create extends Action ->setAttribute('chunksUploaded', $chunksUploaded); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } @@ -431,15 +428,11 @@ class Create extends Action ->setAttribute('metadata', $metadata); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - try { $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } catch (NotFoundException) { @@ -468,8 +461,5 @@ class Create extends Action */ protected function afterCreateSuccess(Document $file) { - if (!($file instanceof Document)) { - throw new Exception('file must be an instance of document'); - } } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f6b6eb25da..4fa5006db8 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -200,7 +200,7 @@ class Get extends Action // when file extension is not provided and the mime type is not one of our supported outputs // we fallback to `jpg` output format - $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type; + $output = empty($type) ? (array_search($mime, $outputs) ?: 'jpg') : $type; } $startTime = \microtime(true); @@ -243,7 +243,7 @@ class Get extends Action $image->crop((int) $width, (int) $height, $gravity); - if (!empty($opacity) || $opacity === 0) { + if (!empty($opacity)) { $image->setOpacity($opacity); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 8e69468170..407f3766df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -130,7 +130,7 @@ class Update extends Action } if (\is_null($permissions)) { - $permissions = $file->getPermissions() ?? []; + $permissions = $file->getPermissions(); } $file->setAttribute('$permissions', $permissions); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php index 8f2cd9bbac..d8e5cd5ad2 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php @@ -143,11 +143,12 @@ class XList extends Action }); foreach ($stats as $stat) { - $bucket = $bucketByStatsId[$stat->getId()]; - - if ($bucket) { - $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); + if (!isset($bucketByStatsId[$stat->getId()])) { + continue; } + + $bucket = $bucketByStatsId[$stat->getId()]; + $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); } } catch (\Throwable) { // Stats may not be available, default to 0 diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php index a7bda355da..10a603f5df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php @@ -109,6 +109,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php index 44fdd54e8c..04eac21754 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Storage\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -92,6 +93,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index aa4ee2c66c..e174029031 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -343,7 +343,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (! empty($smtp['senderEmail'])) { @@ -352,8 +353,13 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (! empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (! empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -370,8 +376,13 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (! empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (! empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -379,7 +390,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index f3fd9a4bb9..d146684a20 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -71,9 +71,11 @@ class Get extends Action } $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -113,6 +115,16 @@ class Get extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); $response->dynamic($membership, Response::MODEL_MEMBERSHIP); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 364f92e1c5..70b78e02c6 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -124,9 +124,11 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -167,6 +169,16 @@ class XList extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); return $membership; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php index 69da270e19..c5a8d8f43f 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -104,7 +104,7 @@ class Get extends Action $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); @@ -121,11 +121,11 @@ class Get extends Action if (!empty($code)) { $oauth2 = new OAuth2Github(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), ""); - $accessToken = $oauth2->getAccessToken($code) ?? ''; - $refreshToken = $oauth2->getRefreshToken($code) ?? ''; + $accessToken = $oauth2->getAccessToken($code); + $refreshToken = $oauth2->getRefreshToken($code); $accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code))); - $personalSlug = $oauth2->getUserSlug($accessToken) ?? ''; + $personalSlug = $oauth2->getUserSlug($accessToken); $personal = $personalSlug === $owner; } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 6e1db12c28..33d7e984fb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -107,7 +107,7 @@ trait Deployment $activate = true; } - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php index 7bb2dedaf5..4e7b80f5b2 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php @@ -59,7 +59,7 @@ class Get extends Action ) { $installation = $dbForPlatform->getDocument('installations', $installationId); - if ($installation === false || $installation->isEmpty()) { + if ($installation->isEmpty()) { throw new Exception(Exception::INSTALLATION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 4ed4241d25..8ead94b7cb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -73,9 +73,9 @@ class XList extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -83,7 +83,7 @@ class XList extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $branches = $github->listBranches($owner, $repositoryName) ?? []; + $branches = $github->listBranches($owner, $repositoryName); $response->dynamic(new Document([ 'branches' => \array_map(function ($branch) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php index a0dcec8590..89b38e7b79 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php @@ -79,7 +79,7 @@ class Get extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php index 04003812f8..1918e454a4 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php @@ -152,7 +152,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Provider Error: ' . $repository['message']); } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 6295fcd03b..aa7d7ae95c 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -121,7 +121,7 @@ class Create extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php index 52b94cd525..ec135dc96e 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php @@ -73,9 +73,9 @@ class Get extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -97,7 +97,7 @@ class Get extends Action } } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index dd7bed0137..3e11a4060c 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -109,7 +109,7 @@ class Install extends Action file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data); $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); - $oldVersion = $appwrite?->getImageVersion(); + $oldVersion = $appwrite->getImageVersion(); try { $ports = $compose->getService('traefik')->getPorts(); } catch (\Throwable $th) { @@ -122,10 +122,6 @@ class Install extends Action if ($oldVersion) { foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } - $env = $service->getEnvironment()->list(); foreach ($env as $key => $value) { @@ -177,9 +173,6 @@ class Install extends Action // can be detected by the DB service name or _APP_DB_HOST. $existingDatabase = null; foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $svcEnv = $service->getEnvironment()->list(); if (isset($svcEnv['_APP_DB_ADAPTER'])) { $existingDatabase = $svcEnv['_APP_DB_ADAPTER']; @@ -229,8 +222,8 @@ class Install extends Action $assistantExistsInOldCompose = false; if ($existingInstallation) { try { - $assistantService = $compose->getService('appwrite-assistant'); - $assistantExistsInOldCompose = $assistantService !== null; + $compose->getService('appwrite-assistant'); + $assistantExistsInOldCompose = true; } catch (\Throwable) { /* ignore */ } @@ -290,7 +283,7 @@ class Install extends Action continue; } - if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) { + if ($var['name'] === '_APP_DB_ADAPTER' && $data !== '') { $userInput[$var['name']] = $database; continue; } @@ -334,7 +327,7 @@ class Install extends Action @unlink(InstallerServer::INSTALLER_COMPLETE_FILE); - $state = new State([]); + $state = new State(); $state->clearStaleLock(); $installerConfig = $this->readInstallerConfig(); @@ -608,7 +601,7 @@ class Install extends Action $this->copyMongoEntrypointIfNeeded(); } - if (!$noStart && $startIndex <= 2) { + if (!$noStart) { $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); @@ -838,7 +831,7 @@ class Install extends Action 'email' => $email, 'domain' => $domain, 'database' => $database, - 'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null, + 'ip' => ($hostIp !== $domain) ? $hostIp : null, 'os' => php_uname('s') . ' ' . php_uname('r'), 'arch' => php_uname('m'), 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null, @@ -1365,9 +1358,6 @@ class Install extends Action } foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); $host = $env['_APP_DB_HOST'] ?? null; if ($host !== null && in_array($host, $dbServices, true)) { diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index f5502a5986..7308dc003f 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -75,7 +75,6 @@ class Interval extends Action protected function getTasks(): array { $intervalDomainVerification = (int) System::getEnv('_APP_INTERVAL_DOMAIN_VERIFICATION', '120'); // 2 minutes - $intervalCleanupStaleExecutions = (int) System::getEnv('_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS', '300'); // 5 minutes return [ [ @@ -135,50 +134,4 @@ class Interval extends Action Span::add("interval.domainVerification.processed", $processed); Span::add("interval.domainVerification.failed", $failed); } - - private function cleanupStaleExecutions(Database $dbForPlatform, callable $getProjectDB): void - { - $staleThreshold = DatabaseDateTime::addSeconds(new DateTime(), -1200); // 20 minutes ago - - $scanned = 0; - $processed = 0; - $failed = 0; - - $dbForPlatform->foreach( - 'projects', - function (Document $project) use ($getProjectDB, $staleThreshold, &$scanned, &$processed, &$failed) { - try { - $dbForProject = $getProjectDB($project); - - $staleExecutions = $dbForProject->find('executions', [ - Query::equal('status', ['processing']), - Query::lessThan('$createdAt', $staleThreshold), - Query::limit(100), - ]); - - $scanned += \count($staleExecutions); - - if (\count($staleExecutions) === 0) { - return; - } - - foreach ($staleExecutions as $execution) { - $dbForProject->updateDocument('executions', $execution->getId(), new Document(['status' => 'failed', 'errors' => 'Execution timed out'])); - } - - $processed++; - } catch (\Throwable $th) { - $failed++; - } - }, - [ - Query::equal('region', [System::getEnv('_APP_REGION', 'default')]), - Query::limit(100), - ] - ); - - Span::add("interval.cleanupStaleExecutions.scanned", $scanned); - Span::add("interval.cleanupStaleExecutions.processed", $processed); - Span::add("interval.cleanupStaleExecutions.failed", $failed); - } } diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index aac738915d..b1580f0e68 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -182,7 +182,7 @@ class SDKs extends Action Console::log(''); - if ($createRelease && ! $examplesOnly) { + if ($createRelease) { Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━"); $changelog = $language['changelog'] ?? ''; $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log'; @@ -1150,7 +1150,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (! empty($prListOutput[0])) { $parts = \explode(' ', trim($prListOutput[0]), 2); - $prNumber = $parts[0] ?? ''; + $prNumber = $parts[0]; $prUrl = $parts[1] ?? ''; } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index c55e3d4a6a..1213f78924 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -73,7 +73,7 @@ abstract class ScheduleBase extends Action * 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute * 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker. */ - public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void + public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): never { Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1'); Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started'); diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index f867884801..75908c99c7 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -21,8 +21,6 @@ class ScheduleFunctions extends ScheduleBase public const UPDATE_TIMER = 10; // seconds public const ENQUEUE_TIMER = 60; // seconds - private ?float $lastEnqueueUpdate = null; - public static function getName(): string { return 'schedule-functions'; @@ -43,7 +41,10 @@ class ScheduleFunctions extends ScheduleBase $timerStart = \microtime(true); $time = DateTime::now(); - $enqueueDiff = $this->lastEnqueueUpdate === null ? 0 : $timerStart - $this->lastEnqueueUpdate; + // TODO: Track the last enqueue timestamp to subtract ENQUEUE_TIMER drift from + // the time frame. Previously this used $this->lastEnqueueUpdate as a property + // but enabling the assignment broke scheduling, so the diff stays 0. + $enqueueDiff = 0; $timeFrame = DateTime::addSeconds(new \DateTime(), static::ENQUEUE_TIMER - $enqueueDiff); Console::log("Enqueue tick: started at: $time (with diff $enqueueDiff)"); @@ -128,9 +129,6 @@ class ScheduleFunctions extends ScheduleBase $timerEnd = \microtime(true); - // TODO: This was a bug before because it wasn't passed by reference, enabling it breaks scheduling - //$this->lastEnqueueUpdate = $timerStart; - Console::log("Enqueue tick: {$total} executions were enqueued in " . ($timerEnd - $timerStart) . " seconds"); } } diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php index 59e0b11c89..3b50ed7e00 100644 --- a/src/Appwrite/Platform/Tasks/Screenshot.php +++ b/src/Appwrite/Platform/Tasks/Screenshot.php @@ -40,9 +40,6 @@ class Screenshot extends Action throw new \Exception('Invalid JSON in --variables flag'); } } - if ($variables === null) { - throw new \Exception('Invalid JSON in --variables flag'); - } $templates = Config::getParam('templates-site', []); diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 2c03ad3108..82020b05b1 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -297,6 +297,150 @@ class Specs extends Action ]; } + protected function verifyParsedSpec(array $spec): void + { + $services = []; + foreach ($spec['tags'] ?? [] as $tag) { + if (!\is_array($tag)) { + continue; + } + + $service = $tag['name'] ?? null; + if (!\is_string($service) || $service === '') { + continue; + } + + $services[$this->normalizeSdkName($service)] = $service; + } + + if (empty($services)) { + return; + } + + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + if (empty($enums)) { + return; + } + + $overlaps = []; + foreach ($services as $normalized => $service) { + if (!isset($enums[$normalized])) { + continue; + } + + foreach ($enums[$normalized] as $enum) { + $overlaps[] = "service '{$service}' with enum '{$enum}'"; + } + } + + if (!empty($overlaps)) { + throw new \RuntimeException( + 'Spec service names must not overlap enum names. Overlaps: ' + . \implode(', ', \array_unique($overlaps)) + ); + } + } + + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void + { + if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) { + $enumName = $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + $itemsEnumHandled = false; + if ( + isset($node['items']) + && \is_array($node['items']) + && isset($node['items']['enum']) + && \is_array($node['items']['enum']) + ) { + $enumName = $this->getExplicitSpecEnumName($node['items']) + ?? $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + + $itemsEnumHandled = true; + } + + $explicitEnumName = $this->getExplicitSpecEnumName($node); + if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) { + $this->addSpecEnumName($enums, $explicitEnumName); + } + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + $this->collectSpecEnumNames( + $value, + $enums, + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName), + $key === 'items' && $itemsEnumHandled + ); + } + } + + private function addSpecEnumName(array &$enums, string $name): void + { + $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name); + } + + private function getExplicitSpecEnumName(array $node): ?string + { + $enumName = $node['x-enum-name'] ?? null; + + return \is_string($enumName) && $enumName !== '' ? $enumName : null; + } + + private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string + { + $name = $node['name'] ?? $fallbackName; + + return \is_string($name) && $name !== '' ? $name : null; + } + + private function getChildSpecEnumFallbackName( + array $parent, + int|string $key, + array $child, + ?string $fallbackName + ): ?string { + if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') { + return $child['name']; + } + + if ($key === 'schema' || $key === 'items') { + return $this->getFallbackSpecEnumName($parent, $fallbackName); + } + + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) { + return $key; + } + + return $fallbackName; + } + + private function formatSdkName(string $name): string + { + return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name))); + } + + private function normalizeSdkName(string $name): string + { + return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name)); + } + public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array { $sdkPlatforms = []; @@ -483,6 +627,7 @@ class Specs extends Action try { $parsedSpecs = $specs->parse(); + $this->verifyParsedSpec($parsedSpecs); } catch (\RuntimeException $e) { throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); } diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index f49674896e..bde73fd05c 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -65,9 +65,6 @@ class Upgrade extends Install $database = null; $compose = new Compose($data); foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); if (isset($env['_APP_DB_ADAPTER'])) { $database = $env['_APP_DB_ADAPTER']; diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index e5a7950945..f6b0345381 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -58,7 +58,7 @@ class Audits extends Action */ public function action(Message $message, callable $getAudit): Commit|NoCommit { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 34234971d9..4d04a3c92c 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -94,7 +94,7 @@ class Certificates extends Action array $plan, ValidatorAuthorization $authorization, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 6801d12b77..8f5397f630 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -96,7 +96,7 @@ class Deletes extends Action DeleteEvent $queueForDeletes, callable $getAudit, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); @@ -304,7 +304,8 @@ class Deletes extends Action $collectionId = match ($document->getAttribute('resourceType')) { 'function' => 'functions', 'execution' => 'executions', - 'message' => 'messages' + 'message' => 'messages', + default => throw new \Exception('Unknown resource type: ' . $document->getAttribute('resourceType')), }; try { diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 99e20be035..404b04ce76 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -34,7 +34,7 @@ class Executions extends Action Message $message, Database $dbForProject, ): void { - $executionMessage = Execution::fromArray($message->getPayload() ?? []); + $executionMessage = Execution::fromArray($message->getPayload()); $execution = $executionMessage->execution; if ($execution->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 0899fbacb4..28c298b050 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -68,7 +68,7 @@ class Functions extends Action Executor $executor, callable $isResourceBlocked ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new AppwriteException( @@ -258,7 +258,7 @@ class Functions extends Action jwt: $jwt, event: null, eventData: null, - executionId: $execution->getId() ?? null + executionId: $execution->getId() ); break; } @@ -437,7 +437,7 @@ class Functions extends Action $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; + $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt ?? ''; $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; @@ -488,12 +488,12 @@ class Functions extends Action // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, 'APPWRITE_FUNCTION_EVENT_DATA' => $body, - 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'] ?? '', - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'], + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -688,7 +688,7 @@ class Functions extends Action if (!empty($error)) { throw new AppwriteException( AppwriteException::GENERAL_SERVER_ERROR, - 'Function execution failed: ' . ($error ?: 'No error message provided'), + 'Function execution failed: ' . $error, $errorCode ); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 32de1e50d6..5cd4639988 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -61,7 +61,7 @@ class Mails extends Action public function action(Message $message, Document $project, Registry $register, Log $log): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); @@ -173,8 +173,10 @@ class Mails extends Action $replyTo = $customMailOptions['replyToEmail'] ?? $replyTo; $replyToName = $customMailOptions['replyToName'] ?? $replyToName; } elseif (!empty($smtp)) { - $replyTo = !empty($smtp['replyTo']) ? $smtp['replyTo'] : ($smtp['senderEmail'] ?? $replyTo); - $replyToName = $smtp['senderName'] ?? $replyToName; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + $replyTo = !empty($smtpReplyToEmail) ? $smtpReplyToEmail : ($smtp['senderEmail'] ?? $replyTo); + $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); } $attachments = null; diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index ff5eb2417a..03adebc4b5 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -96,7 +96,7 @@ class Messaging extends Action UsagePublisher $publisherForUsage ): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -257,7 +257,9 @@ class Messaging extends Action $identifiersForProvider = $identifiers[$providerId]; - $adapter = match ($provider->getAttribute('type')) { + $providerType = $provider->getAttribute('type'); + + $adapter = match ($providerType) { MESSAGE_TYPE_SMS => $this->getSmsAdapter($provider), MESSAGE_TYPE_PUSH => $this->getPushAdapter($provider), MESSAGE_TYPE_EMAIL => $this->getEmailAdapter($provider), @@ -269,18 +271,17 @@ class Messaging extends Action $adapter->getMaxMessagesPerRequest() ); - return batch(\array_map(function ($batch) use ($message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { - return function () use ($batch, $message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return batch(\array_map(function ($batch) use ($message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return function () use ($batch, $message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { $deliveredTotal = 0; $deliveryErrors = []; $messageData = clone $message; $messageData->setAttribute('to', $batch); - $data = match ($provider->getAttribute('type')) { + $data = match ($providerType) { MESSAGE_TYPE_SMS => $this->buildSmsMessage($messageData, $provider), MESSAGE_TYPE_PUSH => $this->buildPushMessage($messageData), MESSAGE_TYPE_EMAIL => $this->buildEmailMessage($dbForProject, $messageData, $provider, $deviceForFiles, $project), - default => throw new \Exception('Provider with the requested ID is of the incorrect type') }; try { diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 339084727d..52ad64d975 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -56,7 +56,7 @@ class Migrations extends Action protected ?Device $deviceForFiles; protected ?Document $project; - protected Document $sourceProject; + protected ?Document $sourceProject = null; /** * @var callable @@ -74,7 +74,6 @@ class Migrations extends Action */ protected array $sourceReport = []; - private string $source; /** * @var callable|null */ @@ -130,7 +129,7 @@ class Migrations extends Action array $plan, Authorization $authorization, ): void { - $migrationMessage = Migration::fromArray($message->getPayload() ?? []); + $migrationMessage = Migration::fromArray($message->getPayload()); $this->getDatabasesDB = $getDatabasesDB; $this->getProjectDB = $getProjectDB; @@ -392,6 +391,9 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', + 'templates.read', + 'templates.write', ] ]); diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index db214f5d32..2706d33e2a 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -68,7 +68,7 @@ class StatsResources extends Action { $this->logError = $logError; - $statsResources = StatsResourcesMessage::fromArray($message->getPayload() ?? []); + $statsResources = StatsResourcesMessage::fromArray($message->getPayload()); if ($statsResources->project->isEmpty()) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 144c429629..dad444b381 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -151,7 +151,7 @@ class StatsUsage extends Action { $this->getLogsDB = $getLogsDB; $this->register = $register; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 509f0a6313..5b0497dbea 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -57,7 +57,7 @@ class Webhooks extends Action public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void { $this->errors = []; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..30df5acf52 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -40,6 +40,9 @@ abstract class Format 'license.url' => '', ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array OAUTH_PROVIDER_BLACKLIST = [ [ 'namespace' => 'account', @@ -67,6 +70,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array PROVIDER_USAGE_BLACKLIST = [ [ 'namespace' => 'users', @@ -78,6 +84,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, required?: bool, nullable?: bool}> + */ private const array REQUEST_PARAMETER_OVERRIDES = [ [ 'namespace' => 'project', @@ -109,24 +118,7 @@ abstract class Format { $blacklist = []; - foreach (self::OAUTH_PROVIDER_BLACKLIST as $config) { - foreach ($config['methods'] as $method) { - $entry = [ - 'namespace' => $config['namespace'], - 'method' => $method, - 'parameter' => $config['parameter'], - ]; - if (isset($config['excludeKeys'])) { - $entry['excludeKeys'] = $config['excludeKeys']; - } - if (isset($config['exclude'])) { - $entry['exclude'] = $config['exclude']; - } - $blacklist[] = $entry; - } - } - - foreach (self::PROVIDER_USAGE_BLACKLIST as $config) { + foreach ([...self::OAUTH_PROVIDER_BLACKLIST, ...self::PROVIDER_USAGE_BLACKLIST] as $config) { foreach ($config['methods'] as $method) { $entry = [ 'namespace' => $config['namespace'], @@ -751,6 +743,15 @@ abstract class Format break; case 'project': switch ($method) { + case 'getEmailTemplate': + case 'updateEmailTemplate': + switch ($param) { + case 'templateId': + return 'EmailTemplateType'; + case 'locale': + return 'EmailTemplateLocale'; + } + break; case 'getUsage': switch ($param) { case 'period': @@ -763,7 +764,6 @@ abstract class Format switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': - case 'deleteEmailTemplate': switch ($param) { case 'type': return 'EmailTemplateType'; @@ -959,7 +959,7 @@ abstract class Format 'nullable' => $nullable, ]; - foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + foreach ($this->getRequestParameterOverrides() as $override) { if ( $override['namespace'] !== $service || !\in_array($method, $override['methods'], true) @@ -968,8 +968,12 @@ abstract class Format continue; } - $config['required'] = $override['required'] ?? $config['required']; - $config['nullable'] = $override['nullable'] ?? $config['nullable']; + if (isset($override['required'])) { + $config['required'] = $override['required']; + } + if (isset($override['nullable'])) { + $config['nullable'] = $override['nullable']; + } break; } @@ -978,6 +982,14 @@ abstract class Format return $config; } + /** + * @return list, parameter: string, required?: bool, nullable?: bool}> + */ + private function getRequestParameterOverrides(): array + { + return self::REQUEST_PARAMETER_OVERRIDES; + } + public function getResponseEnumName(string $model, string $param): ?string { if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index fcff6ac2f4..66c2cd7c1c 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -114,16 +114,16 @@ class OpenAPI3 extends Format */ $consumes = [$sdk->getRequestType()->value]; - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -185,7 +185,7 @@ class OpenAPI3 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -291,7 +291,7 @@ class OpenAPI3 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -312,7 +312,7 @@ class OpenAPI3 extends Format $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'content' => [ $produces => [ @@ -326,7 +326,7 @@ class OpenAPI3 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'content' => [ $produces => [ @@ -339,9 +339,9 @@ class OpenAPI3 extends Format } } - if (($response->getCode() ?? 500) === 204) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['content']); + if ($response->getCode() === 204) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['content']); } } @@ -385,7 +385,7 @@ class OpenAPI3 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -404,13 +404,9 @@ class OpenAPI3 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -469,6 +465,7 @@ class OpenAPI3 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -619,7 +616,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -635,7 +632,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['items']['enum'] = $enumValues; - $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['items']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -643,7 +640,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['items']['format'] = $validator->getFormat(); } } else { $node['schema']['type'] = $validator->getType(); @@ -673,7 +670,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -689,7 +686,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['enum'] = $enumValues; - $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -697,7 +694,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['format'] = $validator->getFormat(); } } break; @@ -774,25 +771,17 @@ class OpenAPI3 extends Format /// If the enum flag is Set, add the enum values to the body $body['content'][$consumes[0]]['schema']['properties'][$name]['enum'] = $node['schema']['enum']; $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-name'] = $node['schema']['x-enum-name'] ?? null; - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys'] ?? null; + $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys']; } if ($node['schema']['x-upload-id'] ?? false) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-upload-id'] = $node['schema']['x-upload-id']; } - if (isset($node['default'])) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['default'] = $node['default']; - } - if (\array_key_exists('items', $node['schema'])) { $body['content'][$consumes[0]]['schema']['properties'][$name]['items'] = $node['schema']['items']; } - if ($node['x-global'] ?? false) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; - } - if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 8d47766117..d07d957577 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -114,17 +114,17 @@ class Swagger2 extends Format $consumes = [$sdk->getRequestType()->value]; } - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); $sdkPlatforms = array_values(array_unique($sdkPlatforms)); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -193,7 +193,7 @@ class Swagger2 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -298,7 +298,7 @@ class Swagger2 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -320,7 +320,7 @@ class Swagger2 extends Format foreach ($model as $m) { $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { @@ -332,7 +332,7 @@ class Swagger2 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'schema' => [ '$ref' => '#/definitions/' . $model->getType(), @@ -341,9 +341,9 @@ class Swagger2 extends Format } } - if (in_array($response->getCode() ?? 500, [204, 301, 302, 308], true)) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['schema']); + if (in_array($response->getCode(), [204, 301, 302, 308], true)) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['schema']); } } @@ -387,7 +387,7 @@ class Swagger2 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -406,13 +406,9 @@ class Swagger2 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -471,6 +467,7 @@ class Swagger2 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -624,7 +621,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['items']['format'] = $validator->getFormat(); } } else { $node['type'] = $validator->getType(); @@ -672,7 +669,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['format'] = $validator->getFormat() ?? 'int32'; + $node['format'] = $validator->getFormat(); } } break; @@ -758,11 +755,7 @@ class Swagger2 extends Format /// If the enum flag is Set, add the enum values to the body $body['schema']['properties'][$name]['enum'] = $node['enum']; $body['schema']['properties'][$name]['x-enum-name'] = $node['x-enum-name'] ?? null; - $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys'] ?? null; - } - - if ($node['x-global'] ?? false) { - $body['schema']['properties'][$name]['x-global'] = true; + $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys']; } if ($parameter['nullable']) { diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index f8bdd01103..16bf0909d2 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -188,13 +188,13 @@ class Attributes extends Validator } // Validate required and default conflict - if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when required is true"; return false; } // Validate array and default conflict - if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when array is true"; return false; } @@ -331,7 +331,7 @@ class Attributes extends Validator } // Validate default exists in elements - if (isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['default'])) { if (!in_array($attribute['default'], $attribute['elements'], true)) { $this->message = "Default value for enum attribute '" . $attribute['key'] . "' must be one of the provided elements"; return false; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index 07e27f06cb..587ad58ea4 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -51,18 +51,25 @@ class Webhooks extends Base */ public function isValid($value): bool { - if (\is_array($value)) { - foreach ($value as &$queryString) { - if (!\is_string($queryString)) { - continue; - } - foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { - $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); - } - } - unset($queryString); + return parent::isValid($this->normalizeAliases($value)); + } + + private function normalizeAliases(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; } - return parent::isValid($value); + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); + } + } + unset($queryString); + + return $value; } } diff --git a/src/Appwrite/Utopia/Fetch/BodyMultipart.php b/src/Appwrite/Utopia/Fetch/BodyMultipart.php index ee482a7d9e..90732eb7a1 100644 --- a/src/Appwrite/Utopia/Fetch/BodyMultipart.php +++ b/src/Appwrite/Utopia/Fetch/BodyMultipart.php @@ -64,7 +64,7 @@ class BodyMultipart $partHeaderArray = \explode(':', $partHeader, 2); - $partHeaderName = \strtolower($partHeaderArray[0] ?? ''); + $partHeaderName = \strtolower($partHeaderArray[0]); $partHeaderValue = $partHeaderArray[1] ?? ''; if ($partHeaderName == "content-disposition") { $dispositionChunks = \explode("; ", $partHeaderValue); @@ -92,7 +92,7 @@ class BodyMultipart */ public function getParts(): array { - return $this->parts ?? []; + return $this->parts; } public function getPart(string $key, mixed $default = ''): mixed diff --git a/src/Appwrite/Utopia/Request/Filter.php b/src/Appwrite/Utopia/Request/Filter.php index 4bd9b394a0..638d6f993a 100644 --- a/src/Appwrite/Utopia/Request/Filter.php +++ b/src/Appwrite/Utopia/Request/Filter.php @@ -45,12 +45,6 @@ abstract class Filter */ public function getParamValue(string $key, mixed $default = ''): mixed { - try { - $value = $this->params[$key] ?? $default; - } catch (\Exception $e) { - $value = $default; - } - - return $value; + return $this->params[$key] ?? $default; } } diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index e3d5fe2f79..a290656b6e 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -58,7 +58,7 @@ class V20 extends Filter throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $selections = Query::groupByType($parsed)['selections'] ?? []; + $selections = Query::groupByType($parsed)['selections']; // Check if we need to add wildcard + relationships // This happens when: diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..b10c26c449 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,92 @@ +parseEmailTemplate($content); + break; + case 'project.updateEmailTemplate': + $content = $this->parseEmailTemplate($content); + $content = $this->parseReplyTo($content); + break; + case 'project.updateSMTP': + $content = $this->parseReplyTo($content); + break; + case 'project.updateMembershipPrivacyPolicy': + $content = $this->parseUpdateMembershipPrivacyPolicy($content); + break; + case 'project.updateSessionAlertPolicy': + $content = $this->parseUpdateSessionAlertPolicy($content); + break; + case 'project.updateUserLimitPolicy': + case 'project.updatePasswordHistoryPolicy': + case 'project.updateSessionLimitPolicy': + $content = $this->parseLimitToTotal($content); + break; + } + + return $content; + } + + protected function parseUpdateMembershipPrivacyPolicy(array $content): array + { + $content['userId'] = false; + $content['userPhone'] = false; + + if (isset($content['mfa'])) { + $content['userMFA'] = $content['mfa']; + unset($content['mfa']); + } + + return $content; + } + + protected function parseUpdateSessionAlertPolicy(array $content): array + { + if (isset($content['alerts'])) { + $content['enabled'] = $content['alerts']; + unset($content['alerts']); + } + + return $content; + } + + protected function parseLimitToTotal(array $content): array + { + if (isset($content['limit'])) { + $content['total'] = $content['limit'] === 0 ? null : $content['limit']; + unset($content['limit']); + } + + return $content; + } + + protected function parseEmailTemplate(array $content): array + { + if (isset($content['type'])) { + $content['templateId'] = $content['type']; + unset($content['type']); + } + + return $content; + } + + protected function parseReplyTo(array $content): array + { + if (isset($content['replyTo'])) { + $content['replyToEmail'] = $content['replyTo']; + unset($content['replyTo']); + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V16.php b/src/Appwrite/Utopia/Response/Filters/V16.php index 7eb3ec6eb3..74bae97abb 100644 --- a/src/Appwrite/Utopia/Response/Filters/V16.php +++ b/src/Appwrite/Utopia/Response/Filters/V16.php @@ -40,7 +40,7 @@ class V16 extends Filter } if (isset($content['buildSize'])) { - $content['size'] += + $content['buildSize'] ?? 0; + $content['size'] += +$content['buildSize']; unset($content['buildSize']); } diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..51d223de37 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,62 @@ + $this->parseMembership($content), + Response::MODEL_MEMBERSHIP_LIST => $this->handleList($content, 'memberships', fn ($item) => $this->parseMembership($item)), + Response::MODEL_PROJECT => $this->parseProject($content), + Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)), + Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + default => $content, + }; + } + + private function parseMembership(array $content): array + { + unset($content['userPhone']); + + return $content; + } + + private function parseEmailTemplate(array $content): array + { + if (isset($content['templateId'])) { + $content['type'] = $content['templateId']; + unset($content['templateId']); + } + + if (isset($content['replyToEmail'])) { + $content['replyTo'] = $content['replyToEmail']; + unset($content['replyToEmail']); + } + + unset($content['replyToName']); + unset($content['custom']); + + return $content; + } + + private function parseProject(array $content): array + { + unset($content['authMembershipsUserId']); + unset($content['authMembershipsUserPhone']); + + if (isset($content['smtpReplyToEmail'])) { + $content['smtpReplyTo'] = $content['smtpReplyToEmail']; + unset($content['smtpReplyToEmail']); + } + + unset($content['smtpReplyToName']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php index 46153842bc..9be7102145 100644 --- a/src/Appwrite/Utopia/Response/Model/Membership.php +++ b/src/Appwrite/Utopia/Response/Model/Membership.php @@ -46,6 +46,12 @@ class Membership extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_STRING, + 'description' => 'User phone number. Hide this attribute by toggling membership privacy in the Console.', + 'default' => '', + 'example' => '+1 555 555 5555', + ]) ->addRule('teamId', [ 'type' => self::TYPE_STRING, 'description' => 'Team ID.', diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..97b58d8a51 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -181,6 +181,18 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authMembershipsUserId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user IDs in the teams membership response.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authMembershipsUserPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user phone numbers in the teams membership response.', + 'default' => false, + 'example' => true, + ]) ->addRule('authInvalidateSessions', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not all existing sessions should be invalidated on password change', @@ -247,7 +259,13 @@ class Project extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) - ->addRule('smtpReplyTo', [ + ->addRule('smtpReplyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'SMTP reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) + ->addRule('smtpReplyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP reply to email', 'default' => '', @@ -273,9 +291,9 @@ class Project extends Model ]) ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, - 'description' => 'SMTP server password', + 'description' => 'SMTP server password. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'securepassword', + 'example' => '', ]) ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, @@ -409,11 +427,12 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyTo', $smtp['replyTo'] ?? ''); + $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility + $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); - $document->setAttribute('smtpPassword', $smtp['password'] ?? ''); + $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); } @@ -463,7 +482,7 @@ class Project extends Model $document->setAttribute('authLimit', $authValues['limit'] ?? 0); $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG); - $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); + $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? 0); $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); @@ -472,9 +491,11 @@ class Project extends Model $document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false); $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []); $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); - $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); - $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); - $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); + $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? false); + $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? false); + $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? false); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? false); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? false); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php deleted file mode 100644 index 3ce9cacdb3..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ /dev/null @@ -1,32 +0,0 @@ -addRule('type', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template type', - 'default' => '', - 'example' => 'verification', - ]) - ->addRule('locale', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template locale', - 'default' => '', - 'example' => 'en_us', - ]) - ->addRule('message', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template message', - 'default' => '', - 'example' => 'Click on the link to verify your account.', - ]) - ; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..833de90065 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -3,13 +3,31 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Model; -class TemplateEmail extends Template +class TemplateEmail extends Model { public function __construct() { - parent::__construct(); $this + ->addRule('templateId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template type', + 'default' => '', + 'example' => 'verification', + ]) + ->addRule('locale', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template locale', + 'default' => '', + 'example' => 'en_us', + ]) + ->addRule('message', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template message', + 'default' => '', + 'example' => 'Click on the link to verify your account.', + ]) ->addRule('senderName', [ 'type' => self::TYPE_STRING, 'description' => 'Name of the sender', @@ -22,12 +40,18 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 148b29c1d1..6214bb1f29 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -148,6 +148,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($site['action']['type'] === 'logs') { @@ -195,6 +196,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($function['action']['type'] === 'logs') { @@ -245,7 +247,7 @@ class Comment public function parseComment(string $comment): self { - $state = \explode("\n", $comment)[0] ?? ''; + $state = \explode("\n", $comment)[0]; $state = substr($state, strlen($this->statePrefix)); $json = \base64_decode($state); diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index f899f06bad..a4f1ae44cd 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -297,10 +297,10 @@ class Executor * @param array $params * @param array $headers * @param bool $decode - * @return array|string + * @return array * @throws Exception */ - private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null) + private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null): array { $headers = array_merge($this->headers, $headers); $ch = curl_init($endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); @@ -392,7 +392,7 @@ class Executor $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index d170d56fe4..4358058fe3 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -264,7 +264,7 @@ class Client $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index f6eb963967..4f557e8959 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1605,8 +1605,6 @@ class UsageTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeploymentSite($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static'), diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..86d7de8849 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,9 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', + 'templates.read', + 'templates.write', ], ]); diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index a81da60968..8b4dfd4e3e 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -175,7 +175,7 @@ trait AccountBase // FInd 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); $this->assertStringContainsStringIgnoringCase('Use OTP ' . $code . ' to sign in to '. $this->getProject()['name'] . '. Expires in 15 minutes.', $lastEmail['text']); diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 9f825c3c89..cd2c43381c 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -203,7 +203,7 @@ class AccountConsoleClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 49f0c4c245..c96676b598 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2050,6 +2050,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'alerts' => true, @@ -2135,7 +2136,7 @@ class AccountCustomClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); @@ -3363,7 +3364,7 @@ class AccountCustomClientTest extends Scope { $data = $this->setupPhoneAccount(); $id = $data['id']; - $token = explode(" ", $data['token'])[0] ?? ''; + $token = explode(" ", $data['token'])[0]; $number = $data['number']; /** diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index f5f1d1864c..e3efe3bbd9 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -936,7 +936,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } // Use dedicated collections for this test to avoid conflicts with setupAttributes() $data = $this->setupDatabase(); @@ -1189,7 +1188,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -1221,7 +1219,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1290,7 +1287,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), [ 'content-type' => 'application/json', @@ -1351,7 +1347,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -3324,7 +3319,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3368,7 +3362,7 @@ trait DatabasesBase ]); $this->assertEquals(200, $documents2['headers']['status-code']); - $this->assertEquals(3, $documents2['body']['total']); + $this->assertSame(3, $documents2['body']['total']); $this->assertCount(3, $documents2['body'][$this->getRecordResource()]); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']); @@ -3458,7 +3452,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3531,7 +3524,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3578,7 +3570,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -4929,7 +4920,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), array_merge([ 'content-type' => 'application/json', diff --git a/tests/e2e/Services/Databases/Transactions/ACIDBase.php b/tests/e2e/Services/Databases/Transactions/ACIDBase.php index 1a6ee83b33..11b6de3b70 100644 --- a/tests/e2e/Services/Databases/Transactions/ACIDBase.php +++ b/tests/e2e/Services/Databases/Transactions/ACIDBase.php @@ -178,7 +178,6 @@ trait ACIDBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('This adapter does not support attributes; schema constraint consistency cannot be tested.'); - return; } // Create database diff --git a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php index 7add5c7f71..632b1a62de 100644 --- a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php +++ b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Databases; use Tests\E2E\Client; +use Tests\E2E\Scopes\ApiVectorsDB; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; @@ -16,6 +17,7 @@ class VectorsDBCustomClientTest extends Scope use DatabasesBase; use ProjectCustom; use SideClient; + use ApiVectorsDB; public function testAllowedPermissions(): void { diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index ba518ee0b6..4255774f18 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -567,6 +567,44 @@ class FunctionsCustomServerTest extends Scope }, 120000, 500); } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Single Chunk Range', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $code = $this->packageFunction('basic'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->getDeployment($functionId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupFunction($functionId); + } + public function testCreateFunctionAndDeploymentFromTemplate() { diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php index 8dc2fe337f..ed436ad075 100644 --- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php @@ -184,7 +184,7 @@ class FunctionsClientTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -194,7 +194,7 @@ class FunctionsClientTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -204,7 +204,7 @@ class FunctionsClientTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index 8e1c7ac7e7..572fde49bf 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -186,7 +186,7 @@ class FunctionsServerTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -196,7 +196,7 @@ class FunctionsServerTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -206,7 +206,7 @@ class FunctionsServerTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php index 4a3e49cc60..d3c6d01ffa 100644 --- a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php +++ b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index 25041e843b..dd89819c34 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -112,7 +112,7 @@ class StorageClientTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } /** diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index cc4c8ecec3..1377ef9207 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -111,7 +111,7 @@ class StorageServerTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } public function testGetBuckets(): array diff --git a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php index 9c6910fb30..13f083f0eb 100644 --- a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php +++ b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index ff6e8e3c6f..dd546119e2 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -199,7 +199,7 @@ class TeamsServerTest extends Scope public function testUpdateTeamPrefs() { $team = $this->setupTeamWithPrefs(); - $this->assertIsArray($team); + $this->assertNotEmpty($team); } public function testGetTeamPreferences() diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 9e9ce2fbcd..069dc9cfbb 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -4207,7 +4207,9 @@ trait MigrationsBase }, 30_000, 500); // Check that email was sent with download link - $lastEmail = $this->getLastEmail(); + $lastEmail = $this->getLastEmail(probe: function ($email) { + $this->assertEquals('Your JSON export is ready', $email['subject']); + }); $this->assertNotEmpty($lastEmail); $this->assertEquals('Your JSON export is ready', $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php new file mode 100644 index 0000000000..84f5938d3e --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -0,0 +1,908 @@ +updatePasswordDictionaryPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyDisable(): void + { + $this->updatePasswordDictionaryPolicy(true); + + $response = $this->updatePasswordDictionaryPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPasswordDictionary']); + } + + public function testUpdatePasswordDictionaryPolicyIdempotent(): void + { + $first = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authPasswordDictionary']); + + $second = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordDictionaryPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // ========================================================================= + // Password History Policy + // ========================================================================= + + public function testUpdatePasswordHistoryPolicyEnable(): void + { + $response = $this->updatePasswordHistoryPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authPasswordHistory']); + + // Cleanup (disable by setting total to null which maps to 0) + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMin(): void + { + $response = $this->updatePasswordHistoryPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyDisable(): void + { + $this->updatePasswordHistoryPolicy(5); + + $response = $this->updatePasswordHistoryPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authPasswordHistory']); + } + + public function testUpdatePasswordHistoryPolicyBelowMin(): void + { + $response = $this->updatePasswordHistoryPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyAboveMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordHistoryPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Password Personal Data Policy + // ========================================================================= + + public function testUpdatePasswordPersonalDataPolicyEnable(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPersonalDataCheck']); + + // Cleanup + $this->updatePasswordPersonalDataPolicy(false); + } + + public function testUpdatePasswordPersonalDataPolicyDisable(): void + { + $this->updatePasswordPersonalDataPolicy(true); + + $response = $this->updatePasswordPersonalDataPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPersonalDataCheck']); + } + + public function testUpdatePasswordPersonalDataPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyWithoutAuth(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Alert Policy + // ========================================================================= + + public function testUpdateSessionAlertPolicyEnable(): void + { + $response = $this->updateSessionAlertPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authSessionAlerts']); + + // Cleanup + $this->updateSessionAlertPolicy(false); + } + + public function testUpdateSessionAlertPolicyDisable(): void + { + $this->updateSessionAlertPolicy(true); + + $response = $this->updateSessionAlertPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authSessionAlerts']); + } + + public function testUpdateSessionAlertPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyWithoutAuth(): void + { + $response = $this->updateSessionAlertPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Duration Policy + // ========================================================================= + + public function testUpdateSessionDurationPolicy(): void + { + $response = $this->updateSessionDurationPolicy(3600); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(3600, $response['body']['authDuration']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(3600, $project['body']['authDuration']); + + // Cleanup (reset to default 1 year) + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMin(): void + { + $response = $this->updateSessionDurationPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5, $response['body']['authDuration']); + + // Cleanup + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMax(): void + { + $response = $this->updateSessionDurationPolicy(31536000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(31536000, $response['body']['authDuration']); + } + + public function testUpdateSessionDurationPolicyBelowMin(): void + { + $response = $this->updateSessionDurationPolicy(4); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyAboveMax(): void + { + $response = $this->updateSessionDurationPolicy(31536001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), [ + 'duration' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyWithoutAuth(): void + { + $response = $this->updateSessionDurationPolicy(3600, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Invalidation Policy + // ========================================================================= + + public function testUpdateSessionInvalidationPolicyEnable(): void + { + $response = $this->updateSessionInvalidationPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authInvalidateSessions']); + + // Cleanup + $this->updateSessionInvalidationPolicy(false); + } + + public function testUpdateSessionInvalidationPolicyDisable(): void + { + $this->updateSessionInvalidationPolicy(true); + + $response = $this->updateSessionInvalidationPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authInvalidateSessions']); + } + + public function testUpdateSessionInvalidationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyWithoutAuth(): void + { + $response = $this->updateSessionInvalidationPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Limit Policy + // ========================================================================= + + public function testUpdateSessionLimitPolicy(): void + { + $response = $this->updateSessionLimitPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authSessionsLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authSessionsLimit']); + + // Cleanup (reset to default) + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMin(): void + { + $response = $this->updateSessionLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMax(): void + { + $response = $this->updateSessionLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyDisable(): void + { + $response = $this->updateSessionLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyBelowMin(): void + { + $response = $this->updateSessionLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyAboveMax(): void + { + $response = $this->updateSessionLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyWithoutAuth(): void + { + $response = $this->updateSessionLimitPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // User Limit Policy + // ========================================================================= + + public function testUpdateUserLimitPolicy(): void + { + $response = $this->updateUserLimitPolicy(100); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(100, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(100, $project['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMin(): void + { + $response = $this->updateUserLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMax(): void + { + $response = $this->updateUserLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyDisable(): void + { + $this->updateUserLimitPolicy(100); + + $response = $this->updateUserLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authLimit']); + } + + public function testUpdateUserLimitPolicyBelowMin(): void + { + $response = $this->updateUserLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyAboveMax(): void + { + $response = $this->updateUserLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyWithoutAuth(): void + { + $response = $this->updateUserLimitPolicy(100, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Membership Privacy Policy + // ========================================================================= + + public function testUpdateMembershipPrivacyPolicyAllEnabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authMembershipsUserId']); + $this->assertSame(true, $project['body']['authMembershipsUserEmail']); + $this->assertSame(true, $project['body']['authMembershipsUserPhone']); + $this->assertSame(true, $project['body']['authMembershipsUserName']); + $this->assertSame(true, $project['body']['authMembershipsMfa']); + } + + public function testUpdateMembershipPrivacyPolicyAllDisabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authMembershipsUserId']); + $this->assertSame(false, $project['body']['authMembershipsUserEmail']); + $this->assertSame(false, $project['body']['authMembershipsUserPhone']); + $this->assertSame(false, $project['body']['authMembershipsUserName']); + $this->assertSame(false, $project['body']['authMembershipsMfa']); + + // Cleanup (restore defaults) + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyMixed(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => false, + 'userPhone' => true, + 'userName' => false, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyIndividualFields(): void + { + // Start from a known baseline where every field is enabled + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $fields = [ + 'userId' => 'authMembershipsUserId', + 'userEmail' => 'authMembershipsUserEmail', + 'userPhone' => 'authMembershipsUserPhone', + 'userName' => 'authMembershipsUserName', + 'userMFA' => 'authMembershipsMfa', + ]; + + // Each field can be toggled individually without clobbering the others + foreach ($fields as $param => $attribute) { + $response = $this->updateMembershipPrivacyPolicy([$param => false]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body'][$attribute]); + + foreach ($fields as $otherParam => $otherAttribute) { + if ($otherParam === $param) { + continue; + } + $this->assertSame(true, $response['body'][$otherAttribute], $otherAttribute . ' should be untouched while only ' . $param . ' was updated'); + } + + // Restore the field before the next iteration + $restore = $this->updateMembershipPrivacyPolicy([$param => true]); + $this->assertSame(200, $restore['headers']['status-code']); + $this->assertSame(true, $restore['body'][$attribute]); + } + } + + public function testUpdateMembershipPrivacyPolicyMultipleFields(): void + { + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userPhone' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyEmptyBody(): void + { + // PATCH with no fields should be a no-op, leaving state unchanged + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $response = $this->updateMembershipPrivacyPolicy([]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyWithoutAuth(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function buildHeaders(bool $authenticated = true): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $headers; + } + + protected function getProjectDocument(): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $this->getProject()['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]); + } + + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updatePasswordHistoryPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updatePasswordPersonalDataPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionAlertPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionDurationPolicy(int $duration, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders($authenticated), [ + 'duration' => $duration, + ]); + } + + protected function updateSessionInvalidationPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updateUserLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + /** + * @param array $params + */ + protected function updateMembershipPrivacyPolicy(array $params, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders($authenticated), $params); + } +} diff --git a/tests/e2e/Services/Project/PoliciesConsoleClientTest.php b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php new file mode 100644 index 0000000000..2db8e57a35 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Configure privacy to false + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authMembershipsUserId']); + $this->assertFalse($response['body']['authMembershipsUserEmail']); + $this->assertFalse($response['body']['authMembershipsUserPhone']); + $this->assertFalse($response['body']['authMembershipsUserName']); + $this->assertFalse($response['body']['authMembershipsMfa']); + + // Step 2: Setup two users + $user1Email = 'user1_' . uniqid() . '@localhost.test'; + $user1Name = 'Alice Anderson'; + $user1Phone = '+12025550101'; + $password = 'password1234'; + + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user1Email, + 'password' => $password, + 'name' => $user1Name, + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $user1Id = $user1['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1Id . '/phone', $serverHeaders, [ + 'number' => $user1Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $user2Email = 'user2_' . uniqid() . '@localhost.test'; + $user2Name = 'Bob Baker'; + $user2Phone = '+12025550102'; + + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user2Email, + 'password' => $password, + 'name' => $user2Name, + ]); + $this->assertSame(201, $user2['headers']['status-code']); + $user2Id = $user2['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2Id . '/phone', $serverHeaders, [ + 'number' => $user2Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Create team and add both users as members + $team = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Privacy Team', + 'roles' => ['member'], + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $membership1 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user1Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership1['headers']['status-code']); + $this->assertTrue($membership1['body']['confirm']); + + $membership2 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user2Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership2['headers']['status-code']); + $this->assertTrue($membership2['body']['confirm']); + + // Step 4: Sign in as user1 and list memberships with privacy disabled + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $user1Email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $user1Session = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + foreach ($response['body']['memberships'] as $membership) { + $this->assertSame('', $membership['userName']); + $this->assertSame('', $membership['userEmail']); + $this->assertSame('', $membership['userPhone']); + $this->assertSame('', $membership['userId']); + $this->assertFalse($membership['mfa']); + } + + // Step 5: Update privacy to true + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authMembershipsUserId']); + $this->assertTrue($response['body']['authMembershipsUserEmail']); + $this->assertTrue($response['body']['authMembershipsUserPhone']); + $this->assertTrue($response['body']['authMembershipsUserName']); + $this->assertTrue($response['body']['authMembershipsMfa']); + + // Step 6: List memberships with privacy enabled - user details exposed + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + $membershipsByUser = []; + foreach ($response['body']['memberships'] as $membership) { + $membershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($user1Id, $membershipsByUser); + $this->assertSame($user1Id, $membershipsByUser[$user1Id]['userId']); + $this->assertSame($user1Name, $membershipsByUser[$user1Id]['userName']); + $this->assertSame($user1Email, $membershipsByUser[$user1Id]['userEmail']); + $this->assertSame($user1Phone, $membershipsByUser[$user1Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user1Id]['mfa']); + + $this->assertArrayHasKey($user2Id, $membershipsByUser); + $this->assertSame($user2Id, $membershipsByUser[$user2Id]['userId']); + $this->assertSame($user2Name, $membershipsByUser[$user2Id]['userName']); + $this->assertSame($user2Email, $membershipsByUser[$user2Id]['userEmail']); + $this->assertSame($user2Phone, $membershipsByUser[$user2Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user2Id]['mfa']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php new file mode 100644 index 0000000000..2d0e15a70f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php @@ -0,0 +1,68 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // "password" is the top entry in the common-passwords dictionary and is 8 chars (min length). + $commonPassword = 'football'; + + // Step 1: Disable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authPasswordDictionary']); + + // Step 2: Create user with common password - should succeed + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_off_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary Off User', + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $this->assertNotEmpty($user1['body']['$id']); + + // Step 3: Enable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authPasswordDictionary']); + + // Step 4: Creating another user with the common password must fail + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_on_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary On User', + ]); + $this->assertSame(400, $user2['headers']['status-code']); + + // Cleanup: disable policy + $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php new file mode 100644 index 0000000000..c2dfd7be5e --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php @@ -0,0 +1,152 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Enable password history policy with limit 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authPasswordHistory']); + + $firstPassword = 'firstpassword'; + $secondPassword = 'secondpassword'; + $thirdPassword = 'thirdpassword'; + $fourthPassword = 'fourthpassword'; + + // Step 2: Sign up user with firstpassword (policy on, so signup populates history) + $email = 'history_' . uniqid() . '@localhost.test'; + $userId = ID::unique(); + + $account = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => $userId, + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'History User', + ]); + $this->assertSame(201, $account['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $firstPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + // Change password: first -> second + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $secondPassword, + 'oldPassword' => $firstPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Change password: second -> third + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $thirdPassword, + 'oldPassword' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Attempt to reuse each of the 3 previous passwords - all should fail + foreach ([$firstPassword, $secondPassword, $thirdPassword] as $reused) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $reused, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(400, $response['headers']['status-code'], 'Reusing password "' . $reused . '" should be blocked by history policy'); + $this->assertSame('password_recently_used', $response['body']['type']); + } + + // Step 4: Setting fourthpassword succeeds + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $fourthPassword, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Verify the new password works by signing in again + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fourthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + + // Step 5: Disable password history policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + // Step 6: With policy off, reusing any previous password should succeed, as should setting a brand new one. + // oldPassword must match current password, so walk through each previous password sequentially. + $fifthPassword = 'fifthpassword'; + $chain = [ + [$fourthPassword, $firstPassword], + [$firstPassword, $secondPassword], + [$secondPassword, $thirdPassword], + [$thirdPassword, $fourthPassword], + [$fourthPassword, $fifthPassword], + ]; + + foreach ($chain as [$current, $next]) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $next, + 'oldPassword' => $current, + ]); + $this->assertSame(200, $response['headers']['status-code'], 'Changing password from "' . $current . '" to "' . $next . '" should succeed with history policy disabled'); + } + + // Verify the final password works by signing in + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fifthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php new file mode 100644 index 0000000000..3284fed16f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php @@ -0,0 +1,104 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $setPersonalData = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authPersonalDataCheck']); + }; + + $buildCases = function (): array { + $suffix = \uniqid(); + $userId = 'personaluser' . $suffix; + $emailLocal = 'personalmail' . $suffix; + $email = $emailLocal . '@localhost.test'; + $name = 'Personalname' . $suffix; + $phone = '+12025550' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT); + + return [ + 'userId' => [ + 'userId' => $userId, + 'email' => 'safe_' . $suffix . '@localhost.test', + 'phone' => '+12025559' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => $userId . 'extra', + ], + 'email' => [ + 'userId' => 'safeid' . $suffix, + 'email' => $email, + 'phone' => '+12025558' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => 'prefix_' . $emailLocal . '_suffix', + ], + 'name' => [ + 'userId' => 'safeid2' . $suffix, + 'email' => 'safename_' . $suffix . '@localhost.test', + 'phone' => '+12025557' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => $name, + 'password' => 'prefix' . $name . 'xyz', + ], + 'phone' => [ + 'userId' => 'safeid3' . $suffix, + 'email' => 'safephone_' . $suffix . '@localhost.test', + 'phone' => $phone, + 'name' => 'Safe Name', + 'password' => 'prefix' . \str_replace('+', '', $phone) . 'xyz', + ], + ]; + }; + + $createUser = function (array $params) use ($serverHeaders): array { + return $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => $params['userId'], + 'email' => $params['email'], + 'phone' => $params['phone'], + 'password' => $params['password'], + 'name' => $params['name'], + ]); + }; + + // Step 1: Enable password personal data policy + $setPersonalData(true); + + // Step 2: Each of the four personal-data fields in the password must block user creation + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(400, $response['headers']['status-code'], 'Password containing ' . $field . ' should be rejected'); + $this->assertSame('password_personal_data', $response['body']['type']); + } + + // Step 3: Disable password personal data policy + $setPersonalData(false); + + // Step 4: The same categories of passwords should now be accepted (fresh data to avoid uniqueness conflicts) + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(201, $response['headers']['status-code'], 'Password containing ' . $field . ' should be accepted with policy disabled'); + $this->assertSame($params['userId'], $response['body']['$id']); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php new file mode 100644 index 0000000000..1500a1dcfa --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php @@ -0,0 +1,121 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + $password = 'password1234'; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setSessionAlert = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authSessionAlerts']); + }; + + $createUser = function (string $email) use ($serverHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Alert User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $createSession = function (string $email) use ($publicHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $countEmailsTo = function (string $address): int { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $count = 0; + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $count++; + } + } + } + return $count; + }; + + $assertEmailCountStays = function (string $address, int $expected, int $seconds) use ($countEmailsTo): void { + $deadline = \microtime(true) + $seconds; + while (\microtime(true) < $deadline) { + $this->assertSame($expected, $countEmailsTo($address), 'Unexpected email count for ' . $address); + \usleep(500_000); + } + }; + + // Step 1: Disable session alerts + $setSessionAlert(false); + + // Step 2: Create user1 and two sessions + $user1Email = 'alert1_' . uniqid() . '@localhost.test'; + $createUser($user1Email); + $createSession($user1Email); + $createSession($user1Email); + + // Step 3: No alert should arrive in the next 10 seconds + $assertEmailCountStays($user1Email, 0, 10); + + // Step 4: Enable session alerts + $setSessionAlert(true); + + // Step 5: Create user2 and one session + $user2Email = 'alert2_' . uniqid() . '@localhost.test'; + $createUser($user2Email); + $createSession($user2Email); + + // Step 6: First session never alerts, so nothing arrives in 10 seconds + $assertEmailCountStays($user2Email, 0, 10); + + // Step 7: Create the second session for user2 + $createSession($user2Email); + + // Step 8: Session alert email should eventually arrive + $this->assertEventually(function () use ($countEmailsTo, $user2Email) { + $this->assertSame(1, $countEmailsTo($user2Email)); + }, 15_000, 500); + + // Step 9: Disable session alerts + $setSessionAlert(false); + + // Step 10: Create the third session for user2 + $createSession($user2Email); + + // Step 11: No additional alert email should arrive in 10 seconds + $assertEmailCountStays($user2Email, 1, 10); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php new file mode 100644 index 0000000000..71562f52a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -0,0 +1,92 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setDuration = function (int $seconds) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $serverHeaders, [ + 'duration' => $seconds, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($seconds, $response['body']['authDuration']); + }; + + // Step 1: Set session duration to 5 seconds + $setDuration(5); + + // Step 2: Create user and a session + $email = 'duration_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Duration User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $accountHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Poll until the 5s TTL elapses - session should expire + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Raise duration to 10s - same session should still not be usable + $setDuration(10); + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + + // Step 5: Set duration to 1 year + $setDuration(31536000); + + // Step 6: Same session should still not be usable + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php new file mode 100644 index 0000000000..c9de2be9a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php @@ -0,0 +1,119 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setInvalidation = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authInvalidateSessions']); + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + // Step 1: Disable session invalidation + $setInvalidation(false); + + // Step 2: Create user and two sessions + $email = 'invalidation_' . uniqid() . '@localhost.test'; + $firstPassword = 'firstpassword'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'Invalidation User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + $login = function (string $password) use ($publicHeaders, $email, $projectId): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $projectId]; + }; + + $session1 = $login($firstPassword); + $session2 = $login($firstPassword); + + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + + // Step 3: Change password while invalidation is disabled - both sessions survive + $secondPassword = 'secondpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Enable session invalidation + $setInvalidation(true); + + // Step 5: Change password - both sessions should be invalidated + $thirdPassword = 'thirdpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 6: Disable session invalidation again + $setInvalidation(false); + + // Step 7: Previously-invalidated sessions stay dead + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php new file mode 100644 index 0000000000..295418a974 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -0,0 +1,122 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $email = 'session_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Create user (via API key so signup rules don't interfere) + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + + $login = function () use ($publicHeaders, $email, $password): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $this->getProject()['$id']]; + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + $setSessionLimit = function (?int $total) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $serverHeaders, [ + 'total' => $total, + ]); + $this->assertSame(200, $response['headers']['status-code']); + }; + + // Step 1: Session limit = 1 + $setSessionLimit(1); + + $session1 = $login(); + $this->assertEventually(function () use ($getAccount, $session1) { + $response = $getAccount($session1); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // New session pushes old one out + $session2 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + + // Step 2: Session limit = 2 + $setSessionLimit(2); + + $session3 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + + // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain + $session4 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + + // Step 4: Disable session limit, create 5 new sessions, all should remain usable + $setSessionLimit(null); + + $newSessions = []; + for ($i = 0; $i < 5; $i++) { + $newSessions[] = $login(); + } + + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php new file mode 100644 index 0000000000..5ddcd8aaa1 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php @@ -0,0 +1,87 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $signupHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $signup = function () use ($signupHeaders): array { + return $this->client->call(Client::METHOD_POST, '/account', $signupHeaders, [ + 'userId' => ID::unique(), + 'email' => 'limit_' . uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Limit User', + ]); + }; + + // Step 1: Set user limit to 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authLimit']); + + // Create 3 users - all should succeed + for ($i = 1; $i <= 3; $i++) { + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code'], 'User ' . $i . ' should be created under limit of 3'); + } + + // User 4 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 2: Raise user limit to 4 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 4, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(4, $response['body']['authLimit']); + + // User 4 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + + // User 5 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 3: Remove user limit (null -> stored as 0 -> unlimited) + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + // User 5 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php new file mode 100644 index 0000000000..4bdf073e19 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -0,0 +1,1133 @@ +updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusDisable(): void + { + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + $response = $this->updateSMTP(enabled: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusEnableIdempotent(): void + { + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['smtpEnabled']); + + $second = $this->updateSMTP(enabled: true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusDisableIdempotent(): void + { + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['smtpEnabled']); + + $second = $this->updateSMTP(enabled: false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + enabled: true, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusWithoutAuthentication(): void + { + $response = $this->updateSMTP(enabled: true, authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // Update SMTP tests + + public function testUpdateSMTPCredentials(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPWithOptionalReplyTo(): void + { + $response = $this->updateSMTP( + senderName: 'Full Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'reply@example.com', + replyToName: 'Full Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Full Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('reply@example.com', $response['body']['smtpReplyToEmail']); + $this->assertSame('Full Reply', $response['body']['smtpReplyToName']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPOverwritesPreviousSettings(): void + { + $this->updateSMTP( + senderName: 'First Sender', + senderEmail: 'first@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->updateSMTP( + senderName: 'Second Sender', + senderEmail: 'second@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Second Sender', $response['body']['smtpSenderName']); + $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnablesSMTP(): void + { + // Ensure SMTP is disabled + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPWithoutAuthentication(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'not-an-email', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderName(): void + { + $response = $this->updateSMTP( + senderName: '', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: '', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptyHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: '', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'https://myhost.com/v1', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidReplyToEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: 'invalid', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPSenderNameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'A', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('A', $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPSenderNameMaxLength(): void + { + $name = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: $name, + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPSenderNameTooLong(): void + { + $response = $this->updateSMTP( + senderName: str_repeat('a', 257), + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'u', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('u', $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPUsernameMaxLength(): void + { + $username = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: $username, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($username, $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPUsernameTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: 'p', + ); + + $this->assertSame(200, $response['headers']['status-code']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPPasswordMaxLength(): void + { + $password = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: $password, + ); + + $this->assertSame(200, $response['headers']['status-code']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPPasswordTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPWithoutSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSecure']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPInvalidConnectionEnabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPInvalidConnectionDisabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('Test', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + } + + public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy client sends `replyTo` (not `replyToEmail`). Request filter maps it. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + $headers, + [ + 'enabled' => true, + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@example.com', + 'host' => 'maildev', + 'port' => 1025, + 'replyTo' => 'legacy-reply@example.com', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Legacy Sender', $response['body']['smtpSenderName']); + $this->assertSame('legacy-sender@example.com', $response['body']['smtpSenderEmail']); + + // Response filter must expose smtpReplyTo and strip smtpReplyToEmail / smtpReplyToName. + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToName', $response['body']); + $this->assertSame('legacy-reply@example.com', $response['body']['smtpReplyTo']); + + // Sanity-check: a modern (non-legacy) read sees the new field names. + $modern = $this->updateSMTP(enabled: true); + $this->assertArrayHasKey('smtpReplyToEmail', $modern['body']); + $this->assertSame('legacy-reply@example.com', $modern['body']['smtpReplyToEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestLegacyInlineParams(): void + { + // Seed the project with a distinct SMTP config so we can prove the + // inline (1.9.1-style) params take precedence over project config. + $this->updateSMTP( + senderName: 'Project Sender', + senderEmail: 'project-sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'project-reply@example.com', + replyToName: 'Project Reply', + enabled: false, + ); + + $recipient = 'legacy-smtp-' . \uniqid() . '@appwrite.io'; + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_POST, + '/project/smtp/tests', + $headers, + [ + 'emails' => [$recipient], + 'senderName' => 'Inline Legacy Sender', + 'senderEmail' => 'inline-legacy@appwrite.io', + 'replyTo' => 'inline-legacy-reply@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Verify the email was sent using the inline params (not project SMTP). + $email = $this->getLastEmailByAddress($recipient, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame('inline-legacy@appwrite.io', $email['from'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['from'][0]['name']); + $this->assertSame('inline-legacy-reply@appwrite.io', $email['replyTo'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['replyTo'][0]['name']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPBackwardsCompatibilityDisable(): void + { + // First enable SMTP + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Use the deprecated enabled=false parameter to disable + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPRequiredFieldsOptionalAfterConfigured(): void + { + // Seed with a known configuration so required fields (host, port, senderEmail) are stored. + $this->updateSMTP( + senderName: 'Initial Sender', + senderEmail: 'initial@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Partial update: only update senderName, omitting host/port/senderEmail. + // Required fields should not be re-required because they are already stored. + $response = $this->updateSMTP(senderName: 'Updated Sender'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['smtpSenderName']); + $this->assertSame('initial@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPAllParamsOptionalAfterConfigured(): void + { + // Seed a configuration so all fields are stored. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Issue a PATCH with no params at all. Once previously configured, this must succeed. + $response = $this->updateSMTP(); + + $this->assertSame(200, $response['headers']['status-code']); + // Previously-set values are preserved + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnabledTrueWithInvalidCredentials(): void + { + // Explicitly enabling SMTP with unreachable host/port must throw. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPEnabledFalseWithInvalidCredentials(): void + { + // enabled=false means SMTP is not in use, so invalid credentials must be accepted. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithInvalidCredentialsDoesNotThrow(): void + { + // Ensure SMTP is currently disabled so we aren't enforcing validation on an enabled config. + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and invalid credentials, the request must not throw. + // SMTP remains disabled because the credentials could not be validated. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithValidCredentialsAutoEnables(): void + { + // Start from a disabled state. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and valid credentials, SMTP must be auto-enabled. + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Create SMTP test tests + + public function testCreateSMTPTest(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestMultipleRecipients(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([ + 'recipient1@example.com', + 'recipient2@example.com', + 'recipient3@example.com', + ]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestWhenSMTPDisabled(): void + { + // Ensure SMTP is disabled + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateSMTPTestWithoutAuthentication(): void + { + $response = $this->createSMTPTest(['recipient@example.com'], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateSMTPTestEmptyEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestInvalidEmail(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['not-an-email']); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestExceedsMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 11; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 10; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Integration tests + + public function testCreateSMTPTestEmailDelivery(): void + { + $senderName = 'SMTP Test Sender'; + $senderEmail = 'smtptest@appwrite.io'; + $replyToEmail = 'smtpreply@appwrite.io'; + $replyToName = 'SMTP Reply Team'; + $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; + + // Configure SMTP with reply-to and auth credentials + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + replyToEmail: $replyToEmail, + replyToName: $replyToName, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger test email + $response = $this->createSMTPTest([$recipientEmail]); + + $this->assertSame(204, $response['headers']['status-code']); + + // Verify email arrived via maildev + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); + $this->assertSame($replyToName, $email['replyTo'][0]['name']); + $this->assertSame('Custom SMTP email sample', $email['subject']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testMagicURLLoginUsesCustomSMTP(): void + { + $senderName = 'Custom Auth Mailer'; + $senderEmail = 'authmailer@appwrite.io'; + $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; + + // Configure custom SMTP with auth credentials + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger MagicURL login as a client (no auth headers needed) + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $recipientEmail, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + + // Verify the email arrived with custom SMTP sender details + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertStringContainsString('Login', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Helpers + + protected function updateSMTP( + ?string $senderName = null, + ?string $senderEmail = null, + ?string $host = null, + ?int $port = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + ?string $username = null, + ?string $password = null, + ?string $secure = null, + ?bool $enabled = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + $params = []; + + foreach (['senderName', 'senderEmail', 'host', 'port', 'replyToEmail', 'replyToName', 'username', 'password', 'secure', 'enabled'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + } + + /** + * @param array $emails + */ + protected function createSMTPTest(array $emails, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/smtp/tests', $headers, [ + 'emails' => $emails, + ]); + } +} diff --git a/tests/e2e/Services/Project/SMTPConsoleClientTest.php b/tests/e2e/Services/Project/SMTPConsoleClientTest.php new file mode 100644 index 0000000000..e5962c0960 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPConsoleClientTest.php @@ -0,0 +1,14 @@ +getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + // When locale is omitted, the fallback locale (en) is applied server-side. + $response = $this->getEmailTemplate('recovery'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('recovery', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateAllSupportedTypes(): void + { + $types = [ + 'verification', + 'magicSession', + 'recovery', + 'invitation', + 'mfaChallenge', + 'sessionAlert', + 'otpSession', + ]; + + foreach ($types as $type) { + $response = $this->getEmailTemplate($type, 'en'); + + $this->assertSame(200, $response['headers']['status-code'], "type={$type}"); + $this->assertSame($type, $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject'], "type={$type} must have default subject"); + $this->assertNotEmpty($response['body']['message'], "type={$type} must have default message"); + } + } + + public function testGetEmailTemplateNonDefaultLocale(): void + { + // Even a non-en locale that has no custom template must return defaults. + $response = $this->getEmailTemplate('verification', 'fr'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('fr', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateResponseModel(): void + { + $response = $this->getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testGetEmailTemplateInvalidType(): void + { + $response = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $response = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $response = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetEmailTemplateReturnsCustomValues(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'Custom invitation subject ' . \uniqid(); + $message = 'Custom invitation body ' . \uniqid(); + + $update = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: $subject, + message: $message, + senderName: 'Invitation Sender', + senderEmail: 'invitation@appwrite.io', + replyToEmail: 'reply-invitation@appwrite.io', + replyToName: 'Invitation Reply', + ); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('invitation', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('invitation', $get['body']['templateId']); + $this->assertSame('en', $get['body']['locale']); + $this->assertSame($subject, $get['body']['subject']); + $this->assertSame($message, $get['body']['message']); + $this->assertSame('Invitation Sender', $get['body']['senderName']); + $this->assertSame('invitation@appwrite.io', $get['body']['senderEmail']); + $this->assertSame('reply-invitation@appwrite.io', $get['body']['replyToEmail']); + $this->assertSame('Invitation Reply', $get['body']['replyToName']); + } + + public function testGetEmailTemplateCustomizationIsLocaleScoped(): void + { + $this->ensureSMTPEnabled(); + + $enSubject = 'EN only subject ' . \uniqid(); + $update = $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $enSubject, + message: 'EN only message', + ); + $this->assertSame(200, $update['headers']['status-code']); + + // Another locale must still return its defaults — not the en customization. + $other = $this->getEmailTemplate('mfaChallenge', 'de'); + $this->assertSame(200, $other['headers']['status-code']); + $this->assertSame('de', $other['body']['locale']); + $this->assertNotSame($enSubject, $other['body']['subject']); + } + + // Update email template tests + + public function testUpdateEmailTemplateRequiredFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Please verify your email', + message: 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertSame('Please verify your email', $response['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $response['body']['message']); + } + + public function testUpdateEmailTemplateAllFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: 'Password reset', + message: 'Reset your password', + senderName: 'Security Team', + senderEmail: 'security@appwrite.io', + replyToEmail: 'noreply@appwrite.io', + replyToName: 'No Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Password reset', $response['body']['subject']); + $this->assertSame('Reset your password', $response['body']['message']); + $this->assertSame('Security Team', $response['body']['senderName']); + $this->assertSame('security@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('noreply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('No Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $this->ensureSMTPEnabled(); + + // Omit locale entirely; server falls back to `en`. + $response = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: 'Session alert', + message: 'Someone signed in', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('sessionAlert', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateOverwritesPrevious(): void + { + $this->ensureSMTPEnabled(); + + $first = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'First subject', + message: 'First body', + ); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'Second subject', + message: 'Second body', + ); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second subject', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second subject', $get['body']['subject']); + $this->assertSame('Second body', $get['body']['message']); + } + + public function testUpdateEmailTemplatePartialAfterSeed(): void + { + $this->ensureSMTPEnabled(); + + // Seed a fully configured template. + $seed = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: 'Magic subject', + message: 'Magic body', + senderName: 'Magic Sender', + senderEmail: 'magic@appwrite.io', + replyToEmail: 'magic-reply@appwrite.io', + replyToName: 'Magic Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + // Once seeded, sending just one field is fine: previous subject/message persist. + $response = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + senderName: 'Updated Sender', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['senderName']); + $this->assertSame('Magic subject', $response['body']['subject']); + $this->assertSame('Magic body', $response['body']['message']); + $this->assertSame('magic@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('magic-reply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('Magic Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDifferentLocales(): void + { + $this->ensureSMTPEnabled(); + + $enUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'English subject', + message: 'English body', + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + $this->assertSame('English subject', $enUpdate['body']['subject']); + + $frUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'fr', + subject: 'Sujet francais', + message: 'Corps francais', + ); + $this->assertSame(200, $frUpdate['headers']['status-code']); + $this->assertSame('fr', $frUpdate['body']['locale']); + $this->assertSame('Sujet francais', $frUpdate['body']['subject']); + + // Locales remain independent. + $enGet = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('English subject', $enGet['body']['subject']); + + $frGet = $this->getEmailTemplate('invitation', 'fr'); + $this->assertSame('Sujet francais', $frGet['body']['subject']); + } + + public function testUpdateEmailTemplateResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Model check subject', + message: 'Model check body', + senderName: 'Sender', + senderEmail: 'sender@appwrite.io', + replyToEmail: 'reply@appwrite.io', + replyToName: 'Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testUpdateEmailTemplateSubjectMaxLength(): void + { + $this->ensureSMTPEnabled(); + + $subject = \str_repeat('a', 255); + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'Body', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($subject, $response['body']['subject']); + } + + public function testUpdateEmailTemplateSubjectTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: \str_repeat('a', 256), + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateSenderNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // senderName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + } + + public function testUpdateEmailTemplateReplyToNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // replyToName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateSenderNameTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: \str_repeat('a', 256), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'notATemplate', + locale: 'en', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidLocale(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'not-a-locale', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubjectOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'recovery'/'de' was never customized, so there is no persisted subject + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'de', + subject: null, + message: 'Body only', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateMissingMessageOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'invitation'/'es' was never customized, so there is no persisted message + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'es', + subject: 'Subject only', + message: null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateEmptySubject(): void + { + $this->ensureSMTPEnabled(); + + // Text(255) validator requires min length 1 — empty subject is rejected. + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: '', + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateEmptyMessage(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyToEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void + { + // Custom templates only make sense alongside a custom SMTP configuration. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + ['enabled' => false], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + try { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Should be blocked', + message: 'Should be blocked', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertStringContainsStringIgnoringCase('SMTP', $response['body']['message']); + } finally { + $this->ensureSMTPEnabled(); + } + } + + // Backwards compatibility (x-appwrite-response-format: 1.9.1) + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $response = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + ); + + $this->assertSame(200, $response['headers']['status-code']); + // The 1.9.1 response filter renames templateId -> type and strips replyToName. + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('verification', $response['body']['type']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyRequestAndResponse(): void + { + $this->ensureSMTPEnabled(); + + // Legacy clients send `type` + `replyTo`; request filter maps both. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy subject', + 'message' => 'Legacy body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayHasKey('replyTo', $response['body']); + $this->assertArrayNotHasKey('replyToEmail', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('magicSession', $response['body']['type']); + $this->assertSame('Legacy subject', $response['body']['subject']); + $this->assertSame('Legacy body', $response['body']['message']); + $this->assertSame('Legacy Sender', $response['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('legacy-reply@appwrite.io', $response['body']['replyTo']); + + // Modern clients see the new field names for the exact same record. + $modern = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame('magicSession', $modern['body']['templateId']); + $this->assertSame('legacy-reply@appwrite.io', $modern['body']['replyToEmail']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Session alert integration + + public function testSessionAlertUsesCustomTemplatePerLocale(): void + { + $this->ensureSMTPEnabled(); + + // session-alerts lives under /projects (console scope), so it's driven with the + // root console session rather than the current test's project-scoped headers. + $alertsResponse = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], + ['enabled' => true], + ); + $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); + + $runId = \uniqid(); + $enSubject = "EN alert subject {$runId}"; + $enMessage = "EN alert body marker {$runId}"; + $skSubject = "SK alert subject {$runId}"; + $skMessage = "SK alert body marker {$runId}"; + + // Configure custom EN template via the default-locale path (omit `locale`). + $enUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: $enSubject, + message: $enMessage, + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + + // Configure custom SK template explicitly. + $skUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'sk', + subject: $skSubject, + message: $skMessage, + ); + $this->assertSame(200, $skUpdate['headers']['status-code']); + + // Matrix of request-time locales and the custom template each one must resolve to. + // `de` has no custom template stored, so it must fall back to the `en` custom template. + $cases = [ + ['requestLocale' => 'en', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => null, 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => 'sk', 'expectedSubject' => $skSubject, 'expectedMessageMarker' => $skMessage], + ['requestLocale' => 'de', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ]; + + foreach ($cases as $case) { + $localeLabel = $case['requestLocale'] ?? 'none'; + $email = "session-alert-{$runId}-{$localeLabel}@appwrite.io"; + $password = 'password123'; + + // Fresh user per case so the session count starts at zero. + $create = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session Alert ' . $localeLabel, + ]); + $this->assertSame(201, $create['headers']['status-code'], "create user ({$localeLabel})"); + + // First session must NOT trigger an alert (count === 1 returns early). + $first = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $first['headers']['status-code'], "first session ({$localeLabel})"); + + // Second session — this one triggers the alert, with the test's request locale. + $headers = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + if ($case['requestLocale'] !== null) { + $headers['x-appwrite-locale'] = $case['requestLocale']; + } + $second = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $headers, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $second['headers']['status-code'], "second session ({$localeLabel})"); + + // The custom subject is uniquely tagged per run, so matching it proves both + // that an alert was sent and that the correct locale template was resolved. + $received = $this->getLastEmailByAddress($email, function ($mail) use ($case) { + $this->assertSame($case['expectedSubject'], $mail['subject']); + }); + + $this->assertSame($case['expectedSubject'], $received['subject'], "subject ({$localeLabel})"); + $this->assertStringContainsString( + $case['expectedMessageMarker'], + $received['text'] . $received['html'], + "message marker ({$localeLabel})", + ); + } + } + + // Helpers + + protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); + } + + protected function updateEmailTemplate( + string $templateId, + ?string $locale = null, + ?string $subject = null, + ?string $message = null, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = ['templateId' => $templateId]; + + foreach (['locale', 'subject', 'message', 'senderName', 'senderEmail', 'replyToEmail', 'replyToName'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); + } + + protected function ensureSMTPEnabled(): void + { + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + } +} diff --git a/tests/e2e/Services/Project/TemplatesConsoleClientTest.php b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php new file mode 100644 index 0000000000..d5431074e3 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..ed72d9375c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6,7 +6,6 @@ use Appwrite\Extend\Exception; use Appwrite\Tests\Async; use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; -use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; @@ -831,49 +830,6 @@ class ProjectsConsoleClientTest extends Scope $this->markTestIncomplete( 'This test is failing right now due to functions collection.' ); - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/project/usage', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'startDate' => UsageTest::getToday(), - 'endDate' => UsageTest::getTomorrow(), - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(8, count($response['body'])); - $this->assertNotEmpty($response['body']); - $this->assertIsArray($response['body']['requests']); - $this->assertIsArray($response['body']['network']); - $this->assertIsNumeric($response['body']['executionsTotal']); - $this->assertIsNumeric($response['body']['rowsTotal']); - $this->assertIsNumeric($response['body']['databasesTotal']); - $this->assertIsNumeric($response['body']['bucketsTotal']); - $this->assertIsNumeric($response['body']['usersTotal']); - $this->assertIsNumeric($response['body']['filesStorageTotal']); - $this->assertIsNumeric($response['body']['deploymentStorageTotal']); - $this->assertIsNumeric($response['body']['authPhoneTotal']); - $this->assertIsNumeric($response['body']['authPhoneEstimate']); - - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/projects/empty', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(404, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/id-is-really-long-id-is-really-long-id-is-really-long-id-is-really-long', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(400, $response['headers']['status-code']); } public function testUpdateProject(): void @@ -971,7 +927,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); // Check the project @@ -987,7 +944,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); /** @@ -1121,6 +1079,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1129,10 +1088,45 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); + /** Update Email template, fail due to SMTP disabled */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), [ + 'subject' => 'Please verify your email', + 'message' => 'Please verify your email {{url}}', + 'senderName' => 'Appwrite Custom', + 'senderEmail' => 'custom@appwrite.io', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** Configure custom SMTP pointing to maildev, so changing template is allowed */ + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + /** Update Email template */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'subject' => 'Please verify your email', 'message' => 'Please verify your email {{url}}', @@ -1152,6 +1146,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1219,6 +1214,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', // Intentionally no locale @@ -1237,6 +1233,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', 'locale' => 'sk', @@ -1255,6 +1252,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'alerts' => true, ]); @@ -1397,6 +1395,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 10, // Set session duration to 10 seconds ]); @@ -1464,6 +1463,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 600, // seconds ]); @@ -1484,6 +1484,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, ]); @@ -1540,6 +1541,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -1556,6 +1558,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -1874,6 +1877,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -1952,6 +1956,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -1983,24 +1988,13 @@ class ProjectsConsoleClientTest extends Scope 'region' => System::getEnv('_APP_REGION', 'default') ]); - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2072,6 +2066,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 10, ]); @@ -2084,25 +2079,13 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectWithAuthLimit(); $id = $data['projectId']; - /** - * Test for Failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 25, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** * Test for Success */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2176,6 +2159,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2436,6 +2420,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2493,6 +2478,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2506,6 +2492,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -2525,6 +2512,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2637,6 +2625,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index 6376875157..4d37a8944b 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -803,7 +803,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client->receive(); $this->fail('Expected TimeoutException - event should be filtered by updated query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ca07d45f46..ef1c5fce7a 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3828,7 +3828,7 @@ class RealtimeCustomClientTest extends Scope $this->fail('Should not receive any event after rollback'); } catch (TimeoutException $e) { // Expected - no event should be triggered - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -5655,7 +5655,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Test Document Decrement @@ -5686,7 +5686,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04ed56dae6..04b8400b57 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -101,7 +101,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -206,7 +206,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -304,7 +304,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -398,7 +398,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -492,7 +492,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -604,7 +604,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -716,7 +716,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -810,7 +810,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -903,7 +903,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1019,7 +1019,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with priority > 5 but status != 'active' - should NOT receive event @@ -1041,7 +1041,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1157,7 +1157,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1296,7 +1296,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event @@ -1318,7 +1318,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1511,7 +1511,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1583,7 +1583,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1692,7 +1692,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with matching ID but wrong status - should NOT receive event (only one query matches) @@ -1713,7 +1713,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1870,7 +1870,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientComplex: should receive event, subscriptions should not be empty (query matched) @@ -1912,7 +1912,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientQ2: should receive event, subscriptions should not be empty (query matched) @@ -1929,7 +1929,7 @@ trait RealtimeQueryBase $clientComplex->receive(); $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientAll->close(); @@ -2043,7 +2043,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // 2) pending document -> only queryStatusPending subscription should see it @@ -2073,7 +2073,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientQ1->close(); @@ -2252,7 +2252,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - document does not match query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a NEW document with a different ID - should NOT receive event @@ -2279,7 +2279,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - new document does not match original query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a document with the ORIGINAL matching ID - should receive event @@ -2439,7 +2439,7 @@ trait RealtimeQueryBase $clientWithNonMatchingQuery->receive(); $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientNoQuery->close(); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 2f29f849f6..ec174b8657 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -801,8 +801,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -868,6 +866,46 @@ class SitesCustomServerTest extends Scope // // TODO: Implement testCreateDeploymentFromCLI() later // } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site Single Chunk Range', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + $code = $this->packageSite('static-single-file'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupSite($siteId); + } + public function testCreateDeployment() { $siteId = $this->setupSite([ @@ -881,8 +919,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static-single-file'), @@ -943,8 +979,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -995,8 +1029,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1040,8 +1072,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1243,8 +1273,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1294,8 +1322,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -1383,8 +1409,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1427,8 +1451,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $site = $this->deleteSite($siteId); $this->assertEquals(204, $site['headers']['status-code']); diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 80d73b3bc0..5b04108f71 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -254,7 +254,7 @@ trait TeamsBaseClient $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertFalse($response['body']['mfa']); - $this->assertNotEmpty($response['body']['userId']); + $this->assertArrayHasKey('userId', $response['body']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index 601bf1d2d0..80e406eac9 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -147,7 +147,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); @@ -204,7 +203,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp field'); $expectedExp = (new \DateTime($expiry))->getTimestamp(); @@ -226,7 +224,6 @@ class TokensConsoleClientTest extends Scope // Verify JWT does not contain exp for infinite expiry using native JWT decode try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayNotHasKey('exp', $payload, 'JWT payload should not contain exp field for infinite expiry'); } catch (JWTException $e) { $this->fail('Failed to decode JWT: ' . $e->getMessage()); @@ -265,7 +262,6 @@ class TokensConsoleClientTest extends Scope // Verify the JWT token is valid and contains correct information try { $payload = $jwt->decode($token['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); diff --git a/tests/e2e/Traits/DatabaseFixture.php b/tests/e2e/Traits/DatabaseFixture.php deleted file mode 100644 index f3ba10e765..0000000000 --- a/tests/e2e/Traits/DatabaseFixture.php +++ /dev/null @@ -1,239 +0,0 @@ -ensureFixturesCreated(); - return self::$fixtureDatabaseId; - } - - protected function getFixtureMoviesId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureMoviesId; - } - - protected function getFixtureActorsId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureActorsId; - } - - protected function getFixtureDocumentIds(): array - { - $this->ensureFixturesCreated(); - return self::$fixtureDocumentIds; - } - - protected function ensureFixturesCreated(): void - { - if (self::$fixturesInitialized) { - return; - } - - $this->createDatabaseFixtures(); - self::$fixturesInitialized = true; - } - - protected function createDatabaseFixtures(): void - { - $config = $this->getSchemaApiConfig(); - $isTablesDB = $config['basePath'] === '/tablesdb'; - - // Create database - $database = $this->client->call(Client::METHOD_POST, $config['basePath'], [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'databaseId' => ID::unique(), - 'name' => 'Fixture Database' - ]); - - self::$fixtureDatabaseId = $database['body']['$id']; - $databaseId = self::$fixtureDatabaseId; - - $collectionEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath']; - $collectionKey = $isTablesDB ? 'tableId' : 'collectionId'; - $docKey = $isTablesDB ? 'rowId' : 'documentId'; - $docEndpoint = $isTablesDB ? 'rows' : 'documents'; - - // Create Movies collection - $movies = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Movies', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureMoviesId = $movies['body']['$id']; - - // Create Actors collection - $actors = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Actors', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureActorsId = $actors['body']['$id']; - - // Create attributes on Movies - $attrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'description', - 'size' => 512, - 'required' => false, - 'default' => '', - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/integer', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'releaseYear', - 'required' => false, - 'default' => 0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/float', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'rating', - 'required' => false, - 'default' => 0.0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/boolean', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'active', - 'required' => false, - 'default' => true, - ]); - - // Create attributes on Actors - $actorAttrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureActorsId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $actorAttrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - - $this->waitForAllAttributes($databaseId, self::$fixtureMoviesId); - $this->waitForAllAttributes($databaseId, self::$fixtureActorsId); - - // Create indexes - $indexEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['indexPath']; - - $this->client->call(Client::METHOD_POST, $indexEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title_index', - 'type' => 'key', - 'attributes' => ['title'], - ]); - - $this->waitForAllIndexes($databaseId, self::$fixtureMoviesId); - - // Create sample documents - $docsEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $docEndpoint; - - $sampleMovies = [ - ['title' => 'Inception', 'description' => 'A mind-bending thriller', 'releaseYear' => 2010, 'rating' => 8.8, 'active' => true], - ['title' => 'The Matrix', 'description' => 'A sci-fi classic', 'releaseYear' => 1999, 'rating' => 8.7, 'active' => true], - ['title' => 'Interstellar', 'description' => 'Space exploration epic', 'releaseYear' => 2014, 'rating' => 8.6, 'active' => true], - ]; - - foreach ($sampleMovies as $movie) { - $doc = $this->client->call(Client::METHOD_POST, $docsEndpoint, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - $docKey => ID::unique(), - 'data' => $movie, - 'permissions' => [ - Permission::read(Role::users()), - Permission::update(Role::user($this->getUser()['$id'])), - Permission::delete(Role::user($this->getUser()['$id'])), - ], - ]); - - self::$fixtureDocumentIds[] = $doc['body']['$id']; - } - } - - public static function tearDownAfterClass(): void - { - self::$fixtureDatabaseId = null; - self::$fixtureMoviesId = null; - self::$fixtureActorsId = null; - self::$fixtureDocumentIds = []; - self::$fixturesInitialized = false; - - parent::tearDownAfterClass(); - } -} diff --git a/tests/extensions/Async/Eventually.php b/tests/extensions/Async/Eventually.php index 10f6b41eee..d8c9dc998d 100644 --- a/tests/extensions/Async/Eventually.php +++ b/tests/extensions/Async/Eventually.php @@ -11,7 +11,7 @@ final class Eventually extends Constraint { } - public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): ?bool + public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): bool { if (!is_callable($probe)) { throw new \Exception('Probe must be a callable'); diff --git a/tests/extensions/RetrySubscriber.php b/tests/extensions/RetrySubscriber.php index 08623dc261..ff09b187d4 100644 --- a/tests/extensions/RetrySubscriber.php +++ b/tests/extensions/RetrySubscriber.php @@ -16,13 +16,6 @@ class RetrySubscriber implements FailedSubscriber */ private static array $retryCounts = []; - /** - * Track tests that should be retried - * - * @var array - */ - private static array $pendingRetries = []; - public function notify(Failed $event): void { $this->handleTestFailure($event->test(), $event->throwable()->asString()); @@ -98,6 +91,5 @@ class RetrySubscriber implements FailedSubscriber public static function reset(): void { self::$retryCounts = []; - self::$pendingRetries = []; } } diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php index fc2d839ca6..af6592ef92 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -203,7 +203,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } @@ -240,7 +239,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index 6e4a78022f..845d01e723 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -33,10 +33,7 @@ class DNSTest extends TestCase $result = $validator->isValid('nonexistent-domain-' . \uniqid() . '.com'); $this->assertEquals(false, $result); - $this->assertIsInt($validator->count); - $this->assertIsString($validator->value); - $this->assertIsArray($validator->records); - $this->assertIsString($validator->getDescription()); + $this->assertNotEmpty($validator->getDescription()); } public function testCoreDNSFailure(): void diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index 507a4e25f6..87babcfb16 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -157,7 +157,7 @@ class ModuleTest extends TestCase $platform->init(Service::TYPE_HTTP); // If we get here without exceptions, route registration succeeded - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testModuleHasNoTaskServices(): void @@ -267,14 +267,6 @@ class ModuleTest extends TestCase } } - public function testValidateClassHasCsrfMethod(): void - { - $this->assertTrue( - method_exists(Validate::class, 'validateCsrf'), - 'Validate class should expose validateCsrf method' - ); - } - private function getAction(string $name): Action { $services = $this->module->getServicesByType(Service::TYPE_HTTP); diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php index 6c36e6d732..c8cfd6d884 100644 --- a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php +++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php @@ -19,14 +19,7 @@ class StateTest extends TestCase $this->tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid(); mkdir($this->tempDir, 0755, true); - $root = dirname(__DIR__, 6); - $this->state = new State([ - 'public' => $root . '/public', - 'init' => $root . '/app/init.php', - 'views' => $root . '/app/views/install', - 'vendor' => $root . '/vendor/autoload.php', - 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', - ]); + $this->state = new State(); // Preserve env state $env = getenv('APPWRITE_INSTALLER_CONFIG'); @@ -273,7 +266,6 @@ class StateTest extends TestCase public function testReadProgressFileReturnsDefaultForMissing(): void { $data = $this->state->readProgressFile('nonexistent-id-' . uniqid()); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -291,7 +283,6 @@ class StateTest extends TestCase ]); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('steps', $data); $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']); @@ -604,7 +595,6 @@ class StateTest extends TestCase file_put_contents($path, 'not valid json {{{'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -618,7 +608,6 @@ class StateTest extends TestCase file_put_contents($path, ''); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertEmpty($data['steps']); } @@ -631,7 +620,6 @@ class StateTest extends TestCase file_put_contents($path, '"just a string"'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertEmpty($data['steps']); } diff --git a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php index c453dcade4..0a360783ac 100644 --- a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php +++ b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php @@ -22,7 +22,6 @@ class AppDomainTest extends TestCase public function testDescription(): void { $this->assertNotEmpty($this->validator->getDescription()); - $this->assertIsString($this->validator->getDescription()); } public function testIsArray(): void diff --git a/tests/unit/URL/URLTest.php b/tests/unit/URL/URLTest.php index ceca1c6304..597d77f74c 100644 --- a/tests/unit/URL/URLTest.php +++ b/tests/unit/URL/URLTest.php @@ -11,7 +11,6 @@ class URLTest extends TestCase { $url = URL::parse('https://appwrite.io:8080/path?query=string¶m=value'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals('8080', $url['port']); @@ -20,7 +19,6 @@ class URLTest extends TestCase $url = URL::parse('https://appwrite.io'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals(null, $url['port']); @@ -29,7 +27,6 @@ class URLTest extends TestCase $url = URL::parse('appwrite-callback-project://'); - $this->assertIsArray($url); $this->assertEquals('appwrite-callback-project', $url['scheme']); $this->assertEquals('', $url['host']); $this->assertEquals(null, $url['port']); @@ -47,7 +44,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io:8080/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -58,7 +54,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -69,7 +64,6 @@ class URLTest extends TestCase 'query' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/', $url); $url = URL::unparse([ @@ -80,7 +74,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -93,7 +86,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://eldad:fux@appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -106,7 +98,6 @@ class URLTest extends TestCase 'fragment' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#', $url); } @@ -114,7 +105,6 @@ class URLTest extends TestCase { $result = URL::parseQuery('param1=value1¶m2=value2'); - $this->assertIsArray($result); $this->assertEquals(['param1' => 'value1', 'param2' => 'value2'], $result); } @@ -122,7 +112,6 @@ class URLTest extends TestCase { $result = URL::unparseQuery(['param1' => 'value1', 'param2' => 'value2']); - $this->assertIsString($result); $this->assertEquals('param1=value1¶m2=value2', $result); } } diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index f7d73eb287..d5507327be 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -659,7 +659,7 @@ class RuntimeQueryTest extends TestCase $query = Query::select(['*']); // Should not throw RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testValidateSelectQueryWithSpecificFields(): void @@ -694,7 +694,7 @@ class RuntimeQueryTest extends TestCase $query = Query::equal('name', ['John']); // Should not throw for non-select queries RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Filter tests with select("*") diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index d5cd5d800a..81e0ead4b3 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -23,7 +23,6 @@ class RequestTest extends TestCase public function testFilters(): void { $this->assertFalse($this->request->hasFilters()); - $this->assertIsArray($this->request->getFilters()); $this->assertEmpty($this->request->getFilters()); $this->request->addFilter(new First()); diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index be8cfdc216..f5a30a5500 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -26,7 +26,6 @@ class ResponseTest extends TestCase public function testFilters(): void { $this->assertFalse($this->response->hasFilters()); - $this->assertIsArray($this->response->getFilters()); $this->assertEmpty($this->response->getFilters()); $this->response->addFilter(new First());