diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac30bd64ed..8e01839ac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -693,7 +693,10 @@ jobs: - name: Installing latest version run: | rm .env - 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 2eaa81ce49..50b0cb3dfc 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -56,6 +56,8 @@ $admins = [ '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 74c14ea933..c5fba3ed2b 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -208,4 +208,12 @@ return [ // List of publicly visible scopes "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 761ea33806..c6a5fd6f97 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -133,9 +133,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, @@ -335,15 +332,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); } @@ -453,7 +450,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', @@ -837,7 +834,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'] ?? '{}'; @@ -1604,7 +1601,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.'); } @@ -1621,7 +1618,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]), ]); @@ -1634,7 +1631,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]), ]); @@ -1646,7 +1643,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) { @@ -1679,15 +1676,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); } @@ -1820,15 +1817,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); } @@ -1954,7 +1951,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; @@ -2178,15 +2175,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); } @@ -2305,8 +2302,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'])) { @@ -2315,8 +2312,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 @@ -2333,8 +2335,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'] ?? ''; @@ -2342,7 +2349,8 @@ Http::post('/v1/account/tokens/magic-url') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -2488,15 +2496,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); } @@ -2623,7 +2631,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'])) { @@ -2632,8 +2641,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 @@ -2650,8 +2664,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'] ?? ''; @@ -2659,7 +2678,8 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -3397,15 +3417,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); } @@ -3437,9 +3457,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()) { @@ -3526,9 +3543,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()) { @@ -3752,7 +3766,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'])) { @@ -3761,8 +3776,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 @@ -3779,8 +3799,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'] ?? ''; @@ -3788,7 +3813,8 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4071,7 +4097,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'])) { @@ -4080,8 +4107,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 @@ -4098,8 +4130,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'] ?? ''; @@ -4107,7 +4144,8 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4618,7 +4656,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 2a0012bd30..58c6a2c29e 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -482,7 +482,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 { @@ -3207,10 +3206,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)) { @@ -3386,10 +3381,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), @@ -3527,10 +3518,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), @@ -4660,7 +4647,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 ea5e5754d6..84f109b975 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; @@ -165,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); @@ -310,435 +301,3 @@ Http::post('/v1/projects/:projectId/jwts') 'scopes' => $scopes ])]), 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); - }); - -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'); - - $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); - }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index a8875fc442..1346812668 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1535,7 +1535,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); } } @@ -1595,9 +1595,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()) { @@ -1681,7 +1678,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); } } @@ -1691,9 +1688,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()) { @@ -2252,8 +2246,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( @@ -2842,6 +2836,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 596fbd0926..2cec14cc1d 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -69,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, Reader $geodb, 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; } @@ -202,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', ''); @@ -217,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', ''))); @@ -304,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); @@ -332,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 @@ -461,10 +462,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'] ]); } @@ -680,9 +681,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 @@ -691,9 +691,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'; @@ -721,14 +720,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', ''); @@ -854,7 +851,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)) { @@ -1507,9 +1504,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); @@ -1624,7 +1621,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'); } @@ -1703,7 +1700,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 6519e1f28f..74a05ce4e4 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; @@ -942,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 8503032266..ce1b34f574 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 c07bc9da8b..54c0053a33 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -71,7 +71,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], }; } @@ -249,11 +249,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; @@ -318,7 +318,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) { @@ -328,7 +328,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 d1bb7584bf..29506bfc9c 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 3f6196c460..7d1731b80d 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -375,7 +375,7 @@ return function (Container $container): void { return $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -478,14 +478,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()] : '')); @@ -1084,7 +1080,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 552823336f..3461ca83e5 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -262,7 +262,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 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 2e53b67901..7d53d2965d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -253,7 +253,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/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/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 1ed7e6a63f..a13c6c4903 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -9,7 +10,6 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -103,9 +103,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 81822df208..ccb421b36d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php @@ -2,13 +2,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -97,9 +97,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/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 72474b03f9..5b2f4ff297 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 @@ -370,10 +357,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/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 8fed46aa00..a6f1ca1b03 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -61,7 +61,7 @@ class Screenshots extends Action ): void { Console::log('Screenshot action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -167,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; 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/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 e70f495bb5..331ad9482e 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -33,6 +33,10 @@ use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update 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; @@ -54,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()); 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/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/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/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/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 e19d705553..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; @@ -393,6 +392,8 @@ class Migrations extends Action '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..67e09cffcb 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'], @@ -763,7 +755,6 @@ abstract class Format switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': - case 'deleteEmailTemplate': switch ($param) { case 'type': return 'EmailTemplateType'; @@ -959,7 +950,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 +959,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 +973,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 index a8a52a7ea0..b10c26c449 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -10,6 +10,17 @@ class V23 extends Filter public function parse(array $content, string $model): array { switch ($model) { + case 'project.getEmailTemplate': + case 'project.deleteEmailTemplate': + $content = $this->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; @@ -58,4 +69,24 @@ class V23 extends Filter 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 index ccceb13f44..51d223de37 100644 --- a/src/Appwrite/Utopia/Response/Filters/V23.php +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -15,6 +15,7 @@ class V23 extends Filter 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, }; } @@ -26,11 +27,36 @@ class V23 extends Filter 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/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 75d92ac013..a599d08a04 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -259,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' => '', @@ -285,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, @@ -421,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'] ?? ''); } 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 d0b9ef4b4f..86d7de8849 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -170,6 +170,8 @@ trait ProjectCustom '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 1ad42750e7..c96676b598 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2136,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); @@ -3364,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/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/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'], + ], + ['alerts' => 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 @@ +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', 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 69dbd7fdf0..59727b8d22 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 */ @@ -881,8 +879,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 +939,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 +989,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 +1032,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 +1233,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 +1282,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -1383,8 +1369,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 +1411,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/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());