diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f91eb8127f..8e01839ac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,7 +692,6 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml rm .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" diff --git a/README-CN.md b/README-CN.md index 2c7402f1ef..212b5bb08d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 31076ffa31..88d527f060 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..50b0cb3dfc 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,9 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'policies.write', + 'templates.read', + 'templates.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..c5fba3ed2b 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,16 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "policies.write" => [ + "description" => + "Access to update project\'s policies", + ], + "templates.read" => [ + "description" => + "Access to read project\'s templates", + ], + "templates.write" => [ + "description" => + "Access to create, update, and delete project\'s templates", + ], ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index daabe9e3a3..c6a5fd6f97 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -450,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', @@ -2302,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'])) { @@ -2312,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 @@ -2330,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'] ?? ''; @@ -2339,7 +2349,8 @@ Http::post('/v1/account/tokens/magic-url') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -2620,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'])) { @@ -2629,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 @@ -2647,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'] ?? ''; @@ -2656,7 +2678,8 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -3271,7 +3294,7 @@ Http::patch('/v1/account/password') } $history[] = $newPassword; - $history = array_slice($history, (count($history) - $historyLimit), $historyLimit); + $history = array_slice($history, -$historyLimit); } if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) { @@ -3743,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'])) { @@ -3752,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 @@ -3770,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'] ?? ''; @@ -3779,7 +3813,8 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4062,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'])) { @@ -4071,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 @@ -4089,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'] ?? ''; @@ -4098,7 +4144,8 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4609,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/projects.php b/app/controllers/api/projects.php index 36fd176a15..bd5d0504cf 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -3,29 +3,21 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; -use Appwrite\Event\Mail; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; -use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; -use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\UID; -use Utopia\Emails\Validator\Email; use Utopia\Http\Http; -use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Hostname; -use Utopia\Validator\Integer; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -138,162 +130,6 @@ Http::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/session-alerts') - ->desc('Update project sessions emails') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionAlerts', - description: '/docs/references/projects/update-session-alerts.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['sessionAlerts'] = $alerts; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/memberships-privacy') - ->desc('Update project memberships privacy attributes') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMembershipsPrivacy', - description: '/docs/references/projects/update-memberships-privacy.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('userName', true, new Boolean(true), 'Set to true to show userName to members of a team.') - ->param('userEmail', true, new Boolean(true), 'Set to true to show email to members of a team.') - ->param('mfa', true, new Boolean(true), 'Set to true to show mfa to members of a team.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $userName, bool $userEmail, bool $mfa, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $mfa; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/limit') - ->desc('Update project users limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthLimit', - description: '/docs/references/projects/update-auth-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(0, APP_LIMIT_USERS), 'Set the max number of users allowed in this project. Use 0 for unlimited.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['limit'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/duration') - ->desc('Update project authentication duration') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthDuration', - description: '/docs/references/projects/update-auth-duration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['duration'] = $duration; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/:method') ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') ->groups(['api', 'projects']) @@ -334,158 +170,6 @@ Http::patch('/v1/projects/:projectId/auth/:method') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/password-history') - ->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordHistory', - description: '/docs/references/projects/update-auth-password-history.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordHistory'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/password-dictionary') - ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordDictionary', - description: '/docs/references/projects/update-auth-password-dictionary.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordDictionary'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/personal-data') - ->desc('Update personal data check') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updatePersonalDataCheck', - description: '/docs/references/projects/update-personal-data-check.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to check a password for similarity with personal data. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['personalDataCheck'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/max-sessions') - ->desc('Update project user sessions limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthSessionsLimit', - description: '/docs/references/projects/update-auth-sessions-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['maxSessions'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) @@ -618,471 +302,29 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -// CUSTOM SMTP and Templates -Http::patch('/v1/projects/:projectId/smtp') - ->desc('Update SMTP') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmtp', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMTP', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMTP', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Enable custom SMTP service') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name', true) - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - // Ensure required params for when enabling SMTP - if ($enabled) { - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.'); - } elseif (empty($senderEmail)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.'); - } elseif (empty($host)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.'); - } elseif (empty($port)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.'); - } - } - - // validate SMTP settings - if ($enabled) { - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - } - - // Save SMTP settings - if ($enabled) { - $smtp = [ - 'enabled' => $enabled, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - } else { - $smtp = [ - 'enabled' => false - ]; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::post('/v1/projects/:projectId/smtp/tests') - ->desc('Create SMTP test') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSmtpTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.createSMTPTest', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSMTPTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') - ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender') - ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('queueForMails') - ->inject('plan') - ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; - - $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); - $template - ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); - - foreach ($emails as $email) { - $queueForMails - ->setSmtpHost($host) - ->setSmtpPort($port) - ->setSmtpUsername($username) - ->setSmtpPassword($password) - ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName) - ->setRecipient($email) - ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') - ->setBody($template->render()) - ->setVariables([]) - ->setSubject($subject) - ->trigger(); - } - - $response->noContent(); - }); - -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - +// Backwards compatibility Http::delete('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $project = $dbForPlatform->getDocument('projects', $projectId); - if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - unset($templates['email.' . $type . '-' . $locale]); $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/auth/session-invalidation') - ->desc('Update invalidate session option of the project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionInvalidation', - description: '/docs/references/projects/update-session-invalidation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['invalidateSessions'] = $enabled; - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); + $response->noContent(); }); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 342ca0648f..1346812668 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -2246,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( diff --git a/app/controllers/general.php b/app/controllers/general.php index a17f0dff04..2cec14cc1d 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -26,6 +26,7 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; use Appwrite\Utopia\Request\Filters\V22 as RequestV22; +use Appwrite\Utopia\Request\Filters\V23 as RequestV23; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -34,6 +35,7 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; +use Appwrite\Utopia\Response\Filters\V23 as ResponseV23; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -894,6 +896,9 @@ Http::init() if (version_compare($requestFormat, '1.9.1', '<')) { $request->addFilter(new RequestV22()); } + if (version_compare($requestFormat, '1.9.2', '<')) { + $request->addFilter(new RequestV23()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -918,6 +923,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.2', '<')) { + $response->addFilter(new ResponseV23()); + } if (version_compare($responseFormat, '1.9.1', '<')) { $response->addFilter(new ResponseV22()); } diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fe3fc1d8fe..8b8c7ee066 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -753,7 +753,12 @@ Http::shutdown() ->inject('project') ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { - $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0; + + if ($sessionLimit === 0) { + return; + } + $session = $response->getPayload(); $userId = $session['userId'] ?? ''; if (empty($userId)) { diff --git a/app/init/constants.php b/app/init/constants.php index fa090a648c..8eacf2fe12 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -25,9 +25,6 @@ const APP_MODE_ADMIN = 'admin'; const APP_PAGING_LIMIT = 12; const APP_LIMIT_COUNT = 5000; const APP_LIMIT_USERS = 10_000; -const APP_LIMIT_USER_PASSWORD_HISTORY = 20; -const APP_LIMIT_USER_SESSIONS_MAX = 100; -const APP_LIMIT_USER_SESSIONS_DEFAULT = 10; const APP_LIMIT_ANTIVIRUS = 20_000_000; //20MB const APP_LIMIT_ENCRYPTION = 20_000_000; //20MB const APP_LIMIT_COMPRESSION = 20_000_000; //20MB @@ -47,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4322; -const APP_VERSION_STABLE = '1.9.1'; +const APP_CACHE_BUSTER = 4323; +const APP_VERSION_STABLE = '1.9.2'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; 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/docs/references/account/create-2fa-challenge.md b/docs/references/account/create-2fa-challenge.md deleted file mode 100644 index ee6ef2f2ac..0000000000 --- a/docs/references/account/create-2fa-challenge.md +++ /dev/null @@ -1 +0,0 @@ -Initialize an MFA challenge of the specified factor. The factor must be available on the account. \ No newline at end of file diff --git a/docs/references/account/delete-session-current.md b/docs/references/account/delete-session-current.md deleted file mode 100644 index d38520f479..0000000000 --- a/docs/references/account/delete-session-current.md +++ /dev/null @@ -1 +0,0 @@ -Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/documentsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/documentsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/documentsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/docs/references/functions/create-build.md b/docs/references/functions/create-build.md deleted file mode 100644 index 160a04c291..0000000000 --- a/docs/references/functions/create-build.md +++ /dev/null @@ -1 +0,0 @@ -Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build. \ No newline at end of file diff --git a/docs/references/functions/create-deployment.md b/docs/references/functions/create-deployment.md deleted file mode 100644 index 3bbdbfc848..0000000000 --- a/docs/references/functions/create-deployment.md +++ /dev/null @@ -1,5 +0,0 @@ -Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID. - -This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions). - -Use the "command" param to set the entrypoint used to execute your code. \ No newline at end of file diff --git a/docs/references/functions/create-execution.md b/docs/references/functions/create-execution.md deleted file mode 100644 index 6089c4ff01..0000000000 --- a/docs/references/functions/create-execution.md +++ /dev/null @@ -1 +0,0 @@ -Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously. \ No newline at end of file diff --git a/docs/references/functions/create-function.md b/docs/references/functions/create-function.md deleted file mode 100644 index 1ac9143f45..0000000000 --- a/docs/references/functions/create-function.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API. \ No newline at end of file diff --git a/docs/references/functions/create-variable.md b/docs/references/functions/create-variable.md deleted file mode 100644 index 40fabd75a8..0000000000 --- a/docs/references/functions/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables. \ No newline at end of file diff --git a/docs/references/functions/delete-deployment.md b/docs/references/functions/delete-deployment.md deleted file mode 100644 index 19c74965bd..0000000000 --- a/docs/references/functions/delete-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Delete a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md deleted file mode 100644 index d7cad98ac1..0000000000 --- a/docs/references/functions/delete-execution.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function execution by its unique ID. diff --git a/docs/references/functions/delete-function.md b/docs/references/functions/delete-function.md deleted file mode 100644 index 92835e3c82..0000000000 --- a/docs/references/functions/delete-function.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-variable.md b/docs/references/functions/delete-variable.md deleted file mode 100644 index 9b1326d96f..0000000000 --- a/docs/references/functions/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-deployment-download.md b/docs/references/functions/get-deployment-download.md deleted file mode 100644 index e662ae2733..0000000000 --- a/docs/references/functions/get-deployment-download.md +++ /dev/null @@ -1 +0,0 @@ -Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download. \ No newline at end of file diff --git a/docs/references/functions/get-deployment.md b/docs/references/functions/get-deployment.md deleted file mode 100644 index 6d73976eb1..0000000000 --- a/docs/references/functions/get-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Get a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-execution.md b/docs/references/functions/get-execution.md deleted file mode 100644 index fc38260bdb..0000000000 --- a/docs/references/functions/get-execution.md +++ /dev/null @@ -1 +0,0 @@ -Get a function execution log by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-function-usage.md b/docs/references/functions/get-function-usage.md deleted file mode 100644 index 4498abb05b..0000000000 --- a/docs/references/functions/get-function-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-function.md b/docs/references/functions/get-function.md deleted file mode 100644 index 557ec316ba..0000000000 --- a/docs/references/functions/get-function.md +++ /dev/null @@ -1 +0,0 @@ -Get a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-functions-usage.md b/docs/references/functions/get-functions-usage.md deleted file mode 100644 index 14427d335d..0000000000 --- a/docs/references/functions/get-functions-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-template.md b/docs/references/functions/get-template.md deleted file mode 100644 index ccdcce7352..0000000000 --- a/docs/references/functions/get-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/get-variable.md b/docs/references/functions/get-variable.md deleted file mode 100644 index f0fa853655..0000000000 --- a/docs/references/functions/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/list-deployments.md b/docs/references/functions/list-deployments.md deleted file mode 100644 index 80bbba1bf6..0000000000 --- a/docs/references/functions/list-deployments.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the function's code deployments. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-executions.md b/docs/references/functions/list-executions.md deleted file mode 100644 index 168c795b20..0000000000 --- a/docs/references/functions/list-executions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the current user function execution logs. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-functions.md b/docs/references/functions/list-functions.md deleted file mode 100644 index 9ad432fdc0..0000000000 --- a/docs/references/functions/list-functions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the project's functions. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-runtimes.md b/docs/references/functions/list-runtimes.md deleted file mode 100644 index d4d3d23b18..0000000000 --- a/docs/references/functions/list-runtimes.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all runtimes that are currently active on your instance. \ No newline at end of file diff --git a/docs/references/functions/list-specifications.md b/docs/references/functions/list-specifications.md deleted file mode 100644 index d65a215827..0000000000 --- a/docs/references/functions/list-specifications.md +++ /dev/null @@ -1 +0,0 @@ -List allowed function specifications for this instance. diff --git a/docs/references/functions/list-templates.md b/docs/references/functions/list-templates.md deleted file mode 100644 index ed43b9cbf4..0000000000 --- a/docs/references/functions/list-templates.md +++ /dev/null @@ -1 +0,0 @@ -List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/list-variables.md b/docs/references/functions/list-variables.md deleted file mode 100644 index 68bd5e17e1..0000000000 --- a/docs/references/functions/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all variables of a specific function. \ No newline at end of file diff --git a/docs/references/functions/update-deployment-build.md b/docs/references/functions/update-deployment-build.md deleted file mode 100644 index d047990adf..0000000000 --- a/docs/references/functions/update-deployment-build.md +++ /dev/null @@ -1 +0,0 @@ -Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details. \ No newline at end of file diff --git a/docs/references/functions/update-function-deployment.md b/docs/references/functions/update-function-deployment.md deleted file mode 100644 index 7a85188842..0000000000 --- a/docs/references/functions/update-function-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint. \ No newline at end of file diff --git a/docs/references/functions/update-function.md b/docs/references/functions/update-function.md deleted file mode 100644 index 5a9a84ad94..0000000000 --- a/docs/references/functions/update-function.md +++ /dev/null @@ -1 +0,0 @@ -Update function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/update-variable.md b/docs/references/functions/update-variable.md deleted file mode 100644 index af2c38aea2..0000000000 --- a/docs/references/functions/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update variable by its unique ID. \ No newline at end of file diff --git a/docs/references/health/get-queue-stats-usage-dump.md b/docs/references/health/get-queue-stats-usage-dump.md deleted file mode 100644 index 3c95da1b8a..0000000000 --- a/docs/references/health/get-queue-stats-usage-dump.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue-tasks.md b/docs/references/health/get-queue-tasks.md deleted file mode 100644 index ea6fa22087..0000000000 --- a/docs/references/health/get-queue-tasks.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of tasks that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue.md b/docs/references/health/get-queue.md deleted file mode 100644 index e4558f941f..0000000000 --- a/docs/references/health/get-queue.md +++ /dev/null @@ -1 +0,0 @@ -Check the Appwrite queue messaging servers are up and connection is successful. \ No newline at end of file diff --git a/docs/references/messaging/delete.md b/docs/references/messaging/delete.md deleted file mode 100644 index b07d020900..0000000000 --- a/docs/references/messaging/delete.md +++ /dev/null @@ -1 +0,0 @@ -Delete a message by its unique ID. \ No newline at end of file diff --git a/docs/references/project/create-variable.md b/docs/references/project/create-variable.md deleted file mode 100644 index 2bbee5bf99..0000000000 --- a/docs/references/project/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/delete-variable.md b/docs/references/project/delete-variable.md deleted file mode 100644 index 9be15f83ca..0000000000 --- a/docs/references/project/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/get-variable.md b/docs/references/project/get-variable.md deleted file mode 100644 index 8636768434..0000000000 --- a/docs/references/project/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/list-variables.md b/docs/references/project/list-variables.md deleted file mode 100644 index fbe191178a..0000000000 --- a/docs/references/project/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/update-variable.md b/docs/references/project/update-variable.md deleted file mode 100644 index 603622b2c7..0000000000 --- a/docs/references/project/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/projects/create-key.md b/docs/references/projects/create-key.md deleted file mode 100644 index d6633d936d..0000000000 --- a/docs/references/projects/create-key.md +++ /dev/null @@ -1 +0,0 @@ -Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project. \ No newline at end of file diff --git a/docs/references/projects/create-platform.md b/docs/references/projects/create-platform.md deleted file mode 100644 index b5d8be0ff9..0000000000 --- a/docs/references/projects/create-platform.md +++ /dev/null @@ -1 +0,0 @@ -Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API. \ No newline at end of file diff --git a/docs/references/projects/create-webhook.md b/docs/references/projects/create-webhook.md deleted file mode 100644 index cd0e93332b..0000000000 --- a/docs/references/projects/create-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur. \ No newline at end of file diff --git a/docs/references/projects/delete-key.md b/docs/references/projects/delete-key.md deleted file mode 100644 index 9f3774b419..0000000000 --- a/docs/references/projects/delete-key.md +++ /dev/null @@ -1 +0,0 @@ -Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls. \ No newline at end of file diff --git a/docs/references/projects/delete-platform.md b/docs/references/projects/delete-platform.md deleted file mode 100644 index 7d538cac26..0000000000 --- a/docs/references/projects/delete-platform.md +++ /dev/null @@ -1 +0,0 @@ -Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project. \ No newline at end of file diff --git a/docs/references/projects/delete-webhook.md b/docs/references/projects/delete-webhook.md deleted file mode 100644 index 74fee2bcec..0000000000 --- a/docs/references/projects/delete-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events. \ No newline at end of file diff --git a/docs/references/projects/get-key.md b/docs/references/projects/get-key.md deleted file mode 100644 index bd6351f420..0000000000 --- a/docs/references/projects/get-key.md +++ /dev/null @@ -1 +0,0 @@ -Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes. \ No newline at end of file diff --git a/docs/references/projects/get-platform.md b/docs/references/projects/get-platform.md deleted file mode 100644 index 87129b829d..0000000000 --- a/docs/references/projects/get-platform.md +++ /dev/null @@ -1 +0,0 @@ -Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations. \ No newline at end of file diff --git a/docs/references/projects/get-webhook.md b/docs/references/projects/get-webhook.md deleted file mode 100644 index 559c73c748..0000000000 --- a/docs/references/projects/get-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project. \ No newline at end of file diff --git a/docs/references/projects/list-keys.md b/docs/references/projects/list-keys.md deleted file mode 100644 index a7b701b0d7..0000000000 --- a/docs/references/projects/list-keys.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all API keys from the current project. \ No newline at end of file diff --git a/docs/references/projects/list-platforms.md b/docs/references/projects/list-platforms.md deleted file mode 100644 index ed9ade0852..0000000000 --- a/docs/references/projects/list-platforms.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all platforms in the project. This endpoint returns an array of all platforms and their configurations. \ No newline at end of file diff --git a/docs/references/projects/list-webhooks.md b/docs/references/projects/list-webhooks.md deleted file mode 100644 index bbbf4c7376..0000000000 --- a/docs/references/projects/list-webhooks.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all webhooks belonging to the project. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/projects/update-api-status-all.md b/docs/references/projects/update-api-status-all.md deleted file mode 100644 index 654070759f..0000000000 --- a/docs/references/projects/update-api-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all API types. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime all at once. \ No newline at end of file diff --git a/docs/references/projects/update-api-status.md b/docs/references/projects/update-api-status.md deleted file mode 100644 index af10a0d4f4..0000000000 --- a/docs/references/projects/update-api-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific API type. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime. \ No newline at end of file diff --git a/docs/references/projects/update-auth-duration.md b/docs/references/projects/update-auth-duration.md deleted file mode 100644 index bdc75fa6f0..0000000000 --- a/docs/references/projects/update-auth-duration.md +++ /dev/null @@ -1 +0,0 @@ -Update how long sessions created within a project should stay active for. \ No newline at end of file diff --git a/docs/references/projects/update-auth-limit.md b/docs/references/projects/update-auth-limit.md deleted file mode 100644 index c8faa3fe37..0000000000 --- a/docs/references/projects/update-auth-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of users allowed in this project. Set to 0 for unlimited users. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-dictionary.md b/docs/references/projects/update-auth-password-dictionary.md deleted file mode 100644 index 1d47d30bb5..0000000000 --- a/docs/references/projects/update-auth-password-dictionary.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against common passwords dictionary. This helps ensure users don't use common and insecure passwords. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-history.md b/docs/references/projects/update-auth-password-history.md deleted file mode 100644 index 3a892915d5..0000000000 --- a/docs/references/projects/update-auth-password-history.md +++ /dev/null @@ -1 +0,0 @@ -Update the authentication password history requirement. Use this endpoint to require new passwords to be different than the last X amount of previously used ones. \ No newline at end of file diff --git a/docs/references/projects/update-auth-sessions-limit.md b/docs/references/projects/update-auth-sessions-limit.md deleted file mode 100644 index 7d5fdffae7..0000000000 --- a/docs/references/projects/update-auth-sessions-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of sessions allowed per user within the project, if the limit is hit the oldest session will be deleted to make room for new sessions. \ No newline at end of file diff --git a/docs/references/projects/update-key.md b/docs/references/projects/update-key.md deleted file mode 100644 index 4934a51497..0000000000 --- a/docs/references/projects/update-key.md +++ /dev/null @@ -1 +0,0 @@ -Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key. \ No newline at end of file diff --git a/docs/references/projects/update-memberships-privacy.md b/docs/references/projects/update-memberships-privacy.md deleted file mode 100644 index a1affc1166..0000000000 --- a/docs/references/projects/update-memberships-privacy.md +++ /dev/null @@ -1 +0,0 @@ -Update project membership privacy settings. Use this endpoint to control what user information is visible to other team members, such as user name, email, and MFA status. \ No newline at end of file diff --git a/docs/references/projects/update-personal-data-check.md b/docs/references/projects/update-personal-data-check.md deleted file mode 100644 index 42847fdbfc..0000000000 --- a/docs/references/projects/update-personal-data-check.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against their personal data. This helps prevent users from using personal information in their passwords. \ No newline at end of file diff --git a/docs/references/projects/update-platform.md b/docs/references/projects/update-platform.md deleted file mode 100644 index d04b07bafd..0000000000 --- a/docs/references/projects/update-platform.md +++ /dev/null @@ -1 +0,0 @@ -Update a platform by its unique ID. Use this endpoint to update the platform's name, key, platform store ID, or hostname. \ No newline at end of file diff --git a/docs/references/projects/update-service-status-all.md b/docs/references/projects/update-service-status-all.md deleted file mode 100644 index f05e7d8c5c..0000000000 --- a/docs/references/projects/update-service-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all services. Use this endpoint to enable or disable all optional services at once. \ No newline at end of file diff --git a/docs/references/projects/update-service-status.md b/docs/references/projects/update-service-status.md deleted file mode 100644 index 9d3b0743a8..0000000000 --- a/docs/references/projects/update-service-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific service. Use this endpoint to enable or disable a service in your project. \ No newline at end of file diff --git a/docs/references/projects/update-session-alerts.md b/docs/references/projects/update-session-alerts.md deleted file mode 100644 index 36859e0c1e..0000000000 --- a/docs/references/projects/update-session-alerts.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable session email alerts. When enabled, users will receive email notifications when new sessions are created. \ No newline at end of file diff --git a/docs/references/projects/update-session-invalidation.md b/docs/references/projects/update-session-invalidation.md deleted file mode 100644 index cbaf378624..0000000000 --- a/docs/references/projects/update-session-invalidation.md +++ /dev/null @@ -1 +0,0 @@ -Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/docs/references/projects/update-webhook-signature.md b/docs/references/projects/update-webhook-signature.md deleted file mode 100644 index 8525a05777..0000000000 --- a/docs/references/projects/update-webhook-signature.md +++ /dev/null @@ -1 +0,0 @@ -Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook. \ No newline at end of file diff --git a/docs/references/projects/update-webhook.md b/docs/references/projects/update-webhook.md deleted file mode 100644 index 745e4aebe1..0000000000 --- a/docs/references/projects/update-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook. \ No newline at end of file diff --git a/docs/references/tablesdb/get-database.md b/docs/references/tablesdb/get-database.md deleted file mode 100644 index 24183f6f6b..0000000000 --- a/docs/references/tablesdb/get-database.md +++ /dev/null @@ -1 +0,0 @@ -Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/vectorsdb/decrement-document-attribute.md b/docs/references/vectorsdb/decrement-document-attribute.md deleted file mode 100644 index b7b32d6148..0000000000 --- a/docs/references/vectorsdb/decrement-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection-logs.md b/docs/references/vectorsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/vectorsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-document-logs.md b/docs/references/vectorsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/vectorsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/increment-document-attribute.md b/docs/references/vectorsdb/increment-document-attribute.md deleted file mode 100644 index 7a19b3fbc7..0000000000 --- a/docs/references/vectorsdb/increment-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-attributes.md b/docs/references/vectorsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/vectorsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/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/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/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php index 3297206ccd..862ab7f26c 100644 --- a/src/Appwrite/Migration/Version/V17.php +++ b/src/Appwrite/Migration/Version/V17.php @@ -262,7 +262,7 @@ class V17 extends Migration * Set default maxSessions */ $document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [ - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT + 'maxSessions' => 10 ])); break; case 'users': diff --git a/src/Appwrite/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/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 24d1c48cf1..8a3506eb13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Text; @@ -31,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'labels.*.update') + // ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( @@ -53,6 +54,7 @@ class Update extends Action ->inject('response') ->inject('dbForPlatform') ->inject('project') + ->inject('authorization') ->callback($this->action(...)); } @@ -63,11 +65,12 @@ class Update extends Action array $labels, Response $response, Database $dbForPlatform, - Document $project + Document $project, + Authorization $authorization ): void { $labels = (array) \array_values(\array_unique($labels)); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]))); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 4b58766751..24669b02b2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -36,7 +36,7 @@ class Delete extends Action ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') - ->label('audits.resource', 'project.platform/{response.$id}') + ->label('audits.resource', 'project.platform/{request.platformId}') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 2fca0ace6c..6c07727150 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -139,7 +139,7 @@ class Create extends Action if (empty($key) && empty($type)) { // Modern request, validate hostname if (empty($hostname)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "hostname" is not optional.'); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php new file mode 100644 index 0000000000..c947ff225a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -0,0 +1,108 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/membership-privacy') + ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy') + ->desc('Update membership privacy policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateMembershipPrivacyPolicy', + description: <<param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.', optional: true) + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.', optional: true) + ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.', optional: true) + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.', optional: true) + ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?bool $userId, + ?bool $userEmail, + ?bool $userPhone, + ?bool $userName, + ?bool $userMFA, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if ($userId !== null) { + $auths['membershipsUserId'] = $userId; + } + if ($userEmail !== null) { + $auths['membershipsUserEmail'] = $userEmail; + } + if ($userPhone !== null) { + $auths['membershipsUserPhone'] = $userPhone; + } + if ($userName !== null) { + $auths['membershipsUserName'] = $userName; + } + if ($userMFA !== null) { + $auths['membershipsMfa'] = $userMFA; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'membership-privacy'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php new file mode 100644 index 0000000000..e2c678abb6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-dictionary') + ->httpAlias('/v1/projects/:projectId/auth/password-dictionary') + ->desc('Update password dictionary policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordDictionaryPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password dictionary policy. Set to true if you want password change to block passwords in the dictionary, or false to allow them. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['passwordDictionary'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-dictionary'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php new file mode 100644 index 0000000000..a8ae81caff --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -0,0 +1,93 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-history') + ->httpAlias('/v1/projects/:projectId/auth/password-history') + ->desc('Update password history policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordHistoryPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['passwordHistory'] = 0; + } else { + $auths['passwordHistory'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-history'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php new file mode 100644 index 0000000000..9db7cf0549 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-personal-data') + ->httpAlias('/v1/projects/:projectId/auth/personal-data') + ->desc('Update password personal data policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordPersonalDataPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['personalDataCheck'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-personal-data'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php new file mode 100644 index 0000000000..22b7a44b04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-alert') + ->httpAlias('/v1/projects/:projectId/auth/session-alerts') + ->desc('Update session alert policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionAlertPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session alert policy. Set to true if you want users to receive email notifications when a sessions are created for their users, or false to not send email alerts.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['sessionAlerts'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-alert'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php new file mode 100644 index 0000000000..ba72c93a6f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-duration') + ->httpAlias('/v1/projects/:projectId/auth/duration') + ->desc('Update session duration policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionDurationPolicy', + description: <<param('duration', null, new Range(5, 31536000), 'Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + int $duration, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['duration'] = $duration; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-duration'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php new file mode 100644 index 0000000000..8f8a959959 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-invalidation') + ->httpAlias('/v1/projects/:projectId/auth/session-invalidation') + ->desc('Update session invalidation policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionInvalidationPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session invalidation policy. Set to true if you want password change to invalidate all sessions of an user, or false to keep sessions active.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['invalidateSessions'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-invalidation'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php new file mode 100644 index 0000000000..382ed6f0d9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-limit') + ->httpAlias('/v1/projects/:projectId/auth/max-sessions') + ->desc('Update session limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['maxSessions'] = 0; + } else { + $auths['maxSessions'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-limit'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php new file mode 100644 index 0000000000..9129b81250 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/user-limit') + ->httpAlias('/v1/projects/:projectId/auth/limit') + ->desc('Update user limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateUserLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + ?int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['limit'] = 0; + } else { + $auths['limit'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'user-limit'); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php new file mode 100644 index 0000000000..7095c2d2d0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -0,0 +1,177 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/smtp/tests') + ->httpAlias('/v1/projects/:projectId/smtp/tests') + ->desc('Create project SMTP test') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'createSMTPTest', + description: <<param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') + ->param('senderName', '', new Text(256), 'Name of the email sender', optional: true, deprecated: true) // Backwards compatibility + ->param('senderEmail', '', new Email(), 'Email of the sender', optional: true, deprecated: true) // Backwards compatibility + ->param('replyTo', '', new Email(), 'Reply to email', optional: true, deprecated: true) // Backwards compatibility + ->param('host', '', new Hostname(), 'SMTP server host name', optional: true, deprecated: true) // Backwards compatibility + ->param('port', null, new Integer(), 'SMTP server port', optional: true, deprecated: true) // Backwards compatibility + ->param('username', '', new Text(256), 'SMTP server username', optional: true, deprecated: true) // Backwards compatibility + ->param('password', '', new Text(256), 'SMTP server password', optional: true, deprecated: true) // Backwards compatibility + ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility + ->inject('response') + ->inject('project') + ->inject('queueForMails') + ->inject('plan') + ->callback($this->action(...)); + } + + /** + * @param array $emails + */ + public function action( + array $emails, + string $paramSenderName, // Backwards compatibility + string $paramSenderEmail, // Backwards compatibility + string $paramReplyTo, // Backwards compatibility + string $paramHost, // Backwards compatibility + ?int $paramPort, // Backwards compatibility + string $paramUsername, // Backwards compatibility + string $paramPassword, // Backwards compatibility + string $paramSecure, // Backwards compatibility + Response $response, + Document $project, + Mail $queueForMails, + array $plan + ): void { + // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config. + // When inline params are provided they are treated as self-contained — project config is ignored + // so legacy (1.9.1) callers do not get project state (e.g. replyToName) leaked into their request. + $hasInlineParams = !empty($paramHost); + + $smtp = $project->getAttribute('smtp', []); + + if (!$hasInlineParams && ($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); + } + + if ($hasInlineParams) { + $senderName = $paramSenderName; + $senderEmail = $paramSenderEmail; + $replyToEmail = $paramReplyTo; + $replyToName = ''; // 1.9.1 inline params did not include replyToName + $host = $paramHost; + $port = $paramPort ?? 0; + $username = $paramUsername; + $password = $paramPassword; + $secure = $paramSecure; + } else { + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Includes backwards compatibility + $replyToName = $smtp['replyToName'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? 0; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + } + + if (empty($senderEmail)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); + } + + if (empty($host)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP host must be configured on the project to send a test email.'); + } + + if (empty($port)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); + } + + // Fallback to sender details when reply-to is not explicitly configured + $replyToEmailDisplay = !empty($replyToEmail) ? $replyToEmail : $senderEmail; + $replyToNameDisplay = !empty($replyToName) ? $replyToName : $senderName; + + $subject = 'Custom SMTP email sample'; + $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); + $template + ->setParam('{{from}}', "{$senderName} ({$senderEmail})") + ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})") + ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) + ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) + ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) + ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) + ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) + ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) + ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); + + foreach ($emails as $email) { + $queueForMails + ->setSmtpHost($host) + ->setSmtpPort($port) + ->setSmtpUsername($username) + ->setSmtpPassword($password) + ->setSmtpSecure($secure) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName) + ->setRecipient($email) + ->setName('') + ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl') + ->setBody($template->render()) + ->setVariables([]) + ->setSubject($subject) + ->trigger(); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php new file mode 100644 index 0000000000..97e723f52c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,173 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP configuration') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + // ->label('event', 'project.smtp.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTP', + description: <<param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', null, new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + ?string $host, + ?int $port, + ?string $username, + ?string $password, + ?string $senderEmail, + ?string $senderName, + ?string $replyToEmail, + ?string $replyToName, + ?string $secure, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Fetch current configuration + $smtp = $project->getAttribute('smtp', []); + + // Apply changes + $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $smtp[$key] = ${$key}; + } + } + + // Backwards compatibility + $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + + if (($smtp['enabled'] ?? false) === true) { + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + } + + // Validate SMTP credentials + // Validate when the caller is explicitly enabling or hasn't expressed a preference + // (so a credentials-only PATCH can auto-enable). Skip only when the caller is + // explicitly keeping/turning SMTP off. + if (\is_null($enabled) || $enabled === true) { + $mail = new PHPMailer(true); + $mail->isSMTP(); + + $mail->Host = $smtp['host'] ?? ''; + $mail->Port = $smtp['port'] ?? ''; + $mail->SMTPSecure = $smtp['secure'] ?? ''; + $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? ''); + + if (!empty($smtp['username'] ?? '')) { + $mail->SMTPAuth = true; + $mail->Username = $smtp['username']; + $mail->Password = $smtp['password'] ?? ''; + } + + if (!empty($smtp['replyToEmail'] ?? '')) { + $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? ''); + } + + $mail->SMTPAutoTLS = false; + $mail->Timeout = 5; + + try { + $valid = $mail->SmtpConnect(); + + if (!$valid) { + throw new \Exception('Connection is not valid.'); + } + + // Auto-enable if configuration is valid + // Dont do this if specifically request to mark disabled + if (\is_null($enabled)) { + $smtp['enabled'] = true; + } + } catch (Throwable $error) { + if (($smtp['enabled'] ?? null) === true) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } + } + } + + // Save configuration + $updates = new Document([ + 'smtp' => $smtp, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php new file mode 100644 index 0000000000..02ba431775 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email/:templateId') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') + ->desc('Get project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + Response $response, + Document $project, + ) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + // Get custom template if available + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + + // Enforced params + $template['templateId'] = $templateId; + $template['locale'] = $locale; + + // Prepare default tempaltes + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $defaultSubject = $localeObj->getText('emails.' . $templateId . '.subject'); + $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); + + // Apply defaults if needed + if (\is_null($template['message'] ?? null)) { + $template['message'] = $defaultMessage; + } + + if (\is_null($template['subject'] ?? null)) { + $template['subject'] = $defaultSubject; + } + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); + } + + protected function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + $message = $message->render(useContent: true); + + return $message; + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php new file mode 100644 index 0000000000..ef93abf683 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -0,0 +1,144 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') + ->desc('Update project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateId].update') + ->label('audits.event', 'project.template.update') + ->label('audits.resource', 'project.template/{response.templateId}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'updateEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true) + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true) + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + ?string $subject, + ?string $message, + ?string $senderName, + ?string $senderEmail, + ?string $replyToEmail, + ?string $replyToName, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + ) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + // Prevent template update if custom SMTP is not configured + $smtp = $project->getAttribute('smtp', []); + if (($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); + } + + // Fetch current configuration + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + + // Apply changes + $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $template[$key] = ${$key}; + } + } + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + // Ensure required fields are set + $requiredKeys = ['subject', 'message']; + foreach ($requiredKeys as $key) { + if (empty($template[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + + // Save configuration + $templates['email.' . $templateId . '-' . $locale] = $template; + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateId', $templateId); + + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $template['subject'], + 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', + ]), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..331ad9482e 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,8 +22,21 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData\Update as UpdatePasswordPersonalDataPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert\Update as UpdateSessionAlertPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Update as UpdateSessionDurationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -45,6 +58,14 @@ class Http extends Service $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); + // SMTP + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + + // Templates + $this->addAction(GetTemplate::getName(), new GetTemplate()); + $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); @@ -73,5 +94,16 @@ class Http extends Service $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform()); $this->addAction(GetPlatform::getName(), new GetPlatform()); $this->addAction(ListPlatforms::getName(), new ListPlatforms()); + + // Policies + $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); + $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); + $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); + $this->addAction(UpdatePasswordPersonalDataPolicy::getName(), new UpdatePasswordPersonalDataPolicy()); + $this->addAction(UpdateSessionAlertPolicy::getName(), new UpdateSessionAlertPolicy()); + $this->addAction(UpdateSessionDurationPolicy::getName(), new UpdateSessionDurationPolicy()); + $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy()); + $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy()); + $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy()); } } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index c509a565cd..363c99dc1f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -107,7 +107,7 @@ class Create extends Action $auth = Config::getParam('auth', []); $auths = [ 'limit' => 0, - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'maxSessions' => 0, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, @@ -120,6 +120,8 @@ class Create extends Action 'membershipsUserName' => false, 'membershipsUserEmail' => false, 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, 'invalidateSessions' => true ]; diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index aa4ee2c66c..e174029031 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -343,7 +343,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (! empty($smtp['senderEmail'])) { @@ -352,8 +353,13 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (! empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } + if (! empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -370,8 +376,13 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (! empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } + if (! empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -379,7 +390,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index f3fd9a4bb9..d146684a20 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -71,9 +71,11 @@ class Get extends Action } $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -113,6 +115,16 @@ class Get extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); $response->dynamic($membership, Response::MODEL_MEMBERSHIP); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 364f92e1c5..70b78e02c6 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -124,9 +124,11 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -167,6 +169,16 @@ class XList extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); return $membership; diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 2c03ad3108..82020b05b1 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -297,6 +297,150 @@ class Specs extends Action ]; } + protected function verifyParsedSpec(array $spec): void + { + $services = []; + foreach ($spec['tags'] ?? [] as $tag) { + if (!\is_array($tag)) { + continue; + } + + $service = $tag['name'] ?? null; + if (!\is_string($service) || $service === '') { + continue; + } + + $services[$this->normalizeSdkName($service)] = $service; + } + + if (empty($services)) { + return; + } + + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + if (empty($enums)) { + return; + } + + $overlaps = []; + foreach ($services as $normalized => $service) { + if (!isset($enums[$normalized])) { + continue; + } + + foreach ($enums[$normalized] as $enum) { + $overlaps[] = "service '{$service}' with enum '{$enum}'"; + } + } + + if (!empty($overlaps)) { + throw new \RuntimeException( + 'Spec service names must not overlap enum names. Overlaps: ' + . \implode(', ', \array_unique($overlaps)) + ); + } + } + + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void + { + if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) { + $enumName = $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + $itemsEnumHandled = false; + if ( + isset($node['items']) + && \is_array($node['items']) + && isset($node['items']['enum']) + && \is_array($node['items']['enum']) + ) { + $enumName = $this->getExplicitSpecEnumName($node['items']) + ?? $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + + $itemsEnumHandled = true; + } + + $explicitEnumName = $this->getExplicitSpecEnumName($node); + if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) { + $this->addSpecEnumName($enums, $explicitEnumName); + } + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + $this->collectSpecEnumNames( + $value, + $enums, + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName), + $key === 'items' && $itemsEnumHandled + ); + } + } + + private function addSpecEnumName(array &$enums, string $name): void + { + $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name); + } + + private function getExplicitSpecEnumName(array $node): ?string + { + $enumName = $node['x-enum-name'] ?? null; + + return \is_string($enumName) && $enumName !== '' ? $enumName : null; + } + + private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string + { + $name = $node['name'] ?? $fallbackName; + + return \is_string($name) && $name !== '' ? $name : null; + } + + private function getChildSpecEnumFallbackName( + array $parent, + int|string $key, + array $child, + ?string $fallbackName + ): ?string { + if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') { + return $child['name']; + } + + if ($key === 'schema' || $key === 'items') { + return $this->getFallbackSpecEnumName($parent, $fallbackName); + } + + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) { + return $key; + } + + return $fallbackName; + } + + private function formatSdkName(string $name): string + { + return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name))); + } + + private function normalizeSdkName(string $name): string + { + return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name)); + } + public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array { $sdkPlatforms = []; @@ -483,6 +627,7 @@ class Specs extends Action try { $parsedSpecs = $specs->parse(); + $this->verifyParsedSpec($parsedSpecs); } catch (\RuntimeException $e) { throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 07c1a5242f..5cd4639988 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -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/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index bef9d3d222..52ad64d975 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -391,6 +391,9 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', + 'templates.read', + 'templates.write', ] ]); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index d48a4b8f3f..243517445e 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -744,6 +744,15 @@ abstract class Format case 'project': case 'projects': switch ($method) { + case 'getEmailTemplate': + case 'updateEmailTemplate': + switch ($param) { + case 'templateId': + return 'EmailTemplateType'; + case 'locale': + return 'EmailTemplateLocale'; + } + break; case 'getUsage': switch ($param) { case 'period': @@ -752,7 +761,6 @@ abstract class Format break; case 'getEmailTemplate': case 'updateEmailTemplate': - case 'deleteEmailTemplate': switch ($param) { case 'type': return 'EmailTemplateType'; diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..b10c26c449 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,92 @@ +parseEmailTemplate($content); + break; + case 'project.updateEmailTemplate': + $content = $this->parseEmailTemplate($content); + $content = $this->parseReplyTo($content); + break; + case 'project.updateSMTP': + $content = $this->parseReplyTo($content); + break; + case 'project.updateMembershipPrivacyPolicy': + $content = $this->parseUpdateMembershipPrivacyPolicy($content); + break; + case 'project.updateSessionAlertPolicy': + $content = $this->parseUpdateSessionAlertPolicy($content); + break; + case 'project.updateUserLimitPolicy': + case 'project.updatePasswordHistoryPolicy': + case 'project.updateSessionLimitPolicy': + $content = $this->parseLimitToTotal($content); + break; + } + + return $content; + } + + protected function parseUpdateMembershipPrivacyPolicy(array $content): array + { + $content['userId'] = false; + $content['userPhone'] = false; + + if (isset($content['mfa'])) { + $content['userMFA'] = $content['mfa']; + unset($content['mfa']); + } + + return $content; + } + + protected function parseUpdateSessionAlertPolicy(array $content): array + { + if (isset($content['alerts'])) { + $content['enabled'] = $content['alerts']; + unset($content['alerts']); + } + + return $content; + } + + protected function parseLimitToTotal(array $content): array + { + if (isset($content['limit'])) { + $content['total'] = $content['limit'] === 0 ? null : $content['limit']; + unset($content['limit']); + } + + return $content; + } + + protected function parseEmailTemplate(array $content): array + { + if (isset($content['type'])) { + $content['templateId'] = $content['type']; + unset($content['type']); + } + + return $content; + } + + protected function parseReplyTo(array $content): array + { + if (isset($content['replyTo'])) { + $content['replyToEmail'] = $content['replyTo']; + unset($content['replyTo']); + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..51d223de37 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,62 @@ + $this->parseMembership($content), + Response::MODEL_MEMBERSHIP_LIST => $this->handleList($content, 'memberships', fn ($item) => $this->parseMembership($item)), + Response::MODEL_PROJECT => $this->parseProject($content), + Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)), + Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + default => $content, + }; + } + + private function parseMembership(array $content): array + { + unset($content['userPhone']); + + return $content; + } + + private function parseEmailTemplate(array $content): array + { + if (isset($content['templateId'])) { + $content['type'] = $content['templateId']; + unset($content['templateId']); + } + + if (isset($content['replyToEmail'])) { + $content['replyTo'] = $content['replyToEmail']; + unset($content['replyToEmail']); + } + + unset($content['replyToName']); + unset($content['custom']); + + return $content; + } + + private function parseProject(array $content): array + { + unset($content['authMembershipsUserId']); + unset($content['authMembershipsUserPhone']); + + if (isset($content['smtpReplyToEmail'])) { + $content['smtpReplyTo'] = $content['smtpReplyToEmail']; + unset($content['smtpReplyToEmail']); + } + + unset($content['smtpReplyToName']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php index 46153842bc..9be7102145 100644 --- a/src/Appwrite/Utopia/Response/Model/Membership.php +++ b/src/Appwrite/Utopia/Response/Model/Membership.php @@ -46,6 +46,12 @@ class Membership extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_STRING, + 'description' => 'User phone number. Hide this attribute by toggling membership privacy in the Console.', + 'default' => '', + 'example' => '+1 555 555 5555', + ]) ->addRule('teamId', [ 'type' => self::TYPE_STRING, 'description' => 'Team ID.', diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..97b58d8a51 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -181,6 +181,18 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authMembershipsUserId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user IDs in the teams membership response.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authMembershipsUserPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user phone numbers in the teams membership response.', + 'default' => false, + 'example' => true, + ]) ->addRule('authInvalidateSessions', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not all existing sessions should be invalidated on password change', @@ -247,7 +259,13 @@ class Project extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) - ->addRule('smtpReplyTo', [ + ->addRule('smtpReplyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'SMTP reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) + ->addRule('smtpReplyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP reply to email', 'default' => '', @@ -273,9 +291,9 @@ class Project extends Model ]) ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, - 'description' => 'SMTP server password', + 'description' => 'SMTP server password. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'securepassword', + 'example' => '', ]) ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, @@ -409,11 +427,12 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyTo', $smtp['replyTo'] ?? ''); + $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility + $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); - $document->setAttribute('smtpPassword', $smtp['password'] ?? ''); + $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); } @@ -463,7 +482,7 @@ class Project extends Model $document->setAttribute('authLimit', $authValues['limit'] ?? 0); $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG); - $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); + $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? 0); $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); @@ -472,9 +491,11 @@ class Project extends Model $document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false); $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []); $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); - $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); - $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); - $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); + $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? false); + $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? false); + $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? false); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? false); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? false); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php deleted file mode 100644 index 3ce9cacdb3..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ /dev/null @@ -1,32 +0,0 @@ -addRule('type', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template type', - 'default' => '', - 'example' => 'verification', - ]) - ->addRule('locale', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template locale', - 'default' => '', - 'example' => 'en_us', - ]) - ->addRule('message', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template message', - 'default' => '', - 'example' => 'Click on the link to verify your account.', - ]) - ; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..833de90065 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -3,13 +3,31 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Model; -class TemplateEmail extends Template +class TemplateEmail extends Model { public function __construct() { - parent::__construct(); $this + ->addRule('templateId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template type', + 'default' => '', + 'example' => 'verification', + ]) + ->addRule('locale', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template locale', + 'default' => '', + 'example' => 'en_us', + ]) + ->addRule('message', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template message', + 'default' => '', + 'example' => 'Click on the link to verify your account.', + ]) ->addRule('senderName', [ 'type' => self::TYPE_STRING, 'description' => 'Name of the sender', @@ -22,12 +40,18 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..86d7de8849 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,9 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', + 'templates.read', + 'templates.write', ], ]); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 888611f4ea..c96676b598 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2050,6 +2050,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'alerts' => true, diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php new file mode 100644 index 0000000000..84f5938d3e --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -0,0 +1,908 @@ +updatePasswordDictionaryPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyDisable(): void + { + $this->updatePasswordDictionaryPolicy(true); + + $response = $this->updatePasswordDictionaryPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPasswordDictionary']); + } + + public function testUpdatePasswordDictionaryPolicyIdempotent(): void + { + $first = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authPasswordDictionary']); + + $second = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordDictionaryPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // ========================================================================= + // Password History Policy + // ========================================================================= + + public function testUpdatePasswordHistoryPolicyEnable(): void + { + $response = $this->updatePasswordHistoryPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authPasswordHistory']); + + // Cleanup (disable by setting total to null which maps to 0) + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMin(): void + { + $response = $this->updatePasswordHistoryPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyDisable(): void + { + $this->updatePasswordHistoryPolicy(5); + + $response = $this->updatePasswordHistoryPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authPasswordHistory']); + } + + public function testUpdatePasswordHistoryPolicyBelowMin(): void + { + $response = $this->updatePasswordHistoryPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyAboveMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordHistoryPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Password Personal Data Policy + // ========================================================================= + + public function testUpdatePasswordPersonalDataPolicyEnable(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPersonalDataCheck']); + + // Cleanup + $this->updatePasswordPersonalDataPolicy(false); + } + + public function testUpdatePasswordPersonalDataPolicyDisable(): void + { + $this->updatePasswordPersonalDataPolicy(true); + + $response = $this->updatePasswordPersonalDataPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPersonalDataCheck']); + } + + public function testUpdatePasswordPersonalDataPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyWithoutAuth(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Alert Policy + // ========================================================================= + + public function testUpdateSessionAlertPolicyEnable(): void + { + $response = $this->updateSessionAlertPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authSessionAlerts']); + + // Cleanup + $this->updateSessionAlertPolicy(false); + } + + public function testUpdateSessionAlertPolicyDisable(): void + { + $this->updateSessionAlertPolicy(true); + + $response = $this->updateSessionAlertPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authSessionAlerts']); + } + + public function testUpdateSessionAlertPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyWithoutAuth(): void + { + $response = $this->updateSessionAlertPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Duration Policy + // ========================================================================= + + public function testUpdateSessionDurationPolicy(): void + { + $response = $this->updateSessionDurationPolicy(3600); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(3600, $response['body']['authDuration']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(3600, $project['body']['authDuration']); + + // Cleanup (reset to default 1 year) + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMin(): void + { + $response = $this->updateSessionDurationPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5, $response['body']['authDuration']); + + // Cleanup + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMax(): void + { + $response = $this->updateSessionDurationPolicy(31536000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(31536000, $response['body']['authDuration']); + } + + public function testUpdateSessionDurationPolicyBelowMin(): void + { + $response = $this->updateSessionDurationPolicy(4); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyAboveMax(): void + { + $response = $this->updateSessionDurationPolicy(31536001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), [ + 'duration' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyWithoutAuth(): void + { + $response = $this->updateSessionDurationPolicy(3600, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Invalidation Policy + // ========================================================================= + + public function testUpdateSessionInvalidationPolicyEnable(): void + { + $response = $this->updateSessionInvalidationPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authInvalidateSessions']); + + // Cleanup + $this->updateSessionInvalidationPolicy(false); + } + + public function testUpdateSessionInvalidationPolicyDisable(): void + { + $this->updateSessionInvalidationPolicy(true); + + $response = $this->updateSessionInvalidationPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authInvalidateSessions']); + } + + public function testUpdateSessionInvalidationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyWithoutAuth(): void + { + $response = $this->updateSessionInvalidationPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Limit Policy + // ========================================================================= + + public function testUpdateSessionLimitPolicy(): void + { + $response = $this->updateSessionLimitPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authSessionsLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authSessionsLimit']); + + // Cleanup (reset to default) + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMin(): void + { + $response = $this->updateSessionLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMax(): void + { + $response = $this->updateSessionLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyDisable(): void + { + $response = $this->updateSessionLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyBelowMin(): void + { + $response = $this->updateSessionLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyAboveMax(): void + { + $response = $this->updateSessionLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyWithoutAuth(): void + { + $response = $this->updateSessionLimitPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // User Limit Policy + // ========================================================================= + + public function testUpdateUserLimitPolicy(): void + { + $response = $this->updateUserLimitPolicy(100); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(100, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(100, $project['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMin(): void + { + $response = $this->updateUserLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMax(): void + { + $response = $this->updateUserLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyDisable(): void + { + $this->updateUserLimitPolicy(100); + + $response = $this->updateUserLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authLimit']); + } + + public function testUpdateUserLimitPolicyBelowMin(): void + { + $response = $this->updateUserLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyAboveMax(): void + { + $response = $this->updateUserLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyWithoutAuth(): void + { + $response = $this->updateUserLimitPolicy(100, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Membership Privacy Policy + // ========================================================================= + + public function testUpdateMembershipPrivacyPolicyAllEnabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authMembershipsUserId']); + $this->assertSame(true, $project['body']['authMembershipsUserEmail']); + $this->assertSame(true, $project['body']['authMembershipsUserPhone']); + $this->assertSame(true, $project['body']['authMembershipsUserName']); + $this->assertSame(true, $project['body']['authMembershipsMfa']); + } + + public function testUpdateMembershipPrivacyPolicyAllDisabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authMembershipsUserId']); + $this->assertSame(false, $project['body']['authMembershipsUserEmail']); + $this->assertSame(false, $project['body']['authMembershipsUserPhone']); + $this->assertSame(false, $project['body']['authMembershipsUserName']); + $this->assertSame(false, $project['body']['authMembershipsMfa']); + + // Cleanup (restore defaults) + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyMixed(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => false, + 'userPhone' => true, + 'userName' => false, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyIndividualFields(): void + { + // Start from a known baseline where every field is enabled + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $fields = [ + 'userId' => 'authMembershipsUserId', + 'userEmail' => 'authMembershipsUserEmail', + 'userPhone' => 'authMembershipsUserPhone', + 'userName' => 'authMembershipsUserName', + 'userMFA' => 'authMembershipsMfa', + ]; + + // Each field can be toggled individually without clobbering the others + foreach ($fields as $param => $attribute) { + $response = $this->updateMembershipPrivacyPolicy([$param => false]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body'][$attribute]); + + foreach ($fields as $otherParam => $otherAttribute) { + if ($otherParam === $param) { + continue; + } + $this->assertSame(true, $response['body'][$otherAttribute], $otherAttribute . ' should be untouched while only ' . $param . ' was updated'); + } + + // Restore the field before the next iteration + $restore = $this->updateMembershipPrivacyPolicy([$param => true]); + $this->assertSame(200, $restore['headers']['status-code']); + $this->assertSame(true, $restore['body'][$attribute]); + } + } + + public function testUpdateMembershipPrivacyPolicyMultipleFields(): void + { + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userPhone' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyEmptyBody(): void + { + // PATCH with no fields should be a no-op, leaving state unchanged + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $response = $this->updateMembershipPrivacyPolicy([]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyWithoutAuth(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function buildHeaders(bool $authenticated = true): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $headers; + } + + protected function getProjectDocument(): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $this->getProject()['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]); + } + + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updatePasswordHistoryPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updatePasswordPersonalDataPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionAlertPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionDurationPolicy(int $duration, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders($authenticated), [ + 'duration' => $duration, + ]); + } + + protected function updateSessionInvalidationPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updateUserLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + /** + * @param array $params + */ + protected function updateMembershipPrivacyPolicy(array $params, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders($authenticated), $params); + } +} diff --git a/tests/e2e/Services/Project/PoliciesConsoleClientTest.php b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php new file mode 100644 index 0000000000..2db8e57a35 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Configure privacy to false + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authMembershipsUserId']); + $this->assertFalse($response['body']['authMembershipsUserEmail']); + $this->assertFalse($response['body']['authMembershipsUserPhone']); + $this->assertFalse($response['body']['authMembershipsUserName']); + $this->assertFalse($response['body']['authMembershipsMfa']); + + // Step 2: Setup two users + $user1Email = 'user1_' . uniqid() . '@localhost.test'; + $user1Name = 'Alice Anderson'; + $user1Phone = '+12025550101'; + $password = 'password1234'; + + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user1Email, + 'password' => $password, + 'name' => $user1Name, + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $user1Id = $user1['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1Id . '/phone', $serverHeaders, [ + 'number' => $user1Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $user2Email = 'user2_' . uniqid() . '@localhost.test'; + $user2Name = 'Bob Baker'; + $user2Phone = '+12025550102'; + + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user2Email, + 'password' => $password, + 'name' => $user2Name, + ]); + $this->assertSame(201, $user2['headers']['status-code']); + $user2Id = $user2['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2Id . '/phone', $serverHeaders, [ + 'number' => $user2Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Create team and add both users as members + $team = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Privacy Team', + 'roles' => ['member'], + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $membership1 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user1Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership1['headers']['status-code']); + $this->assertTrue($membership1['body']['confirm']); + + $membership2 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user2Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership2['headers']['status-code']); + $this->assertTrue($membership2['body']['confirm']); + + // Step 4: Sign in as user1 and list memberships with privacy disabled + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $user1Email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $user1Session = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + foreach ($response['body']['memberships'] as $membership) { + $this->assertSame('', $membership['userName']); + $this->assertSame('', $membership['userEmail']); + $this->assertSame('', $membership['userPhone']); + $this->assertSame('', $membership['userId']); + $this->assertFalse($membership['mfa']); + } + + // Step 5: Update privacy to true + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authMembershipsUserId']); + $this->assertTrue($response['body']['authMembershipsUserEmail']); + $this->assertTrue($response['body']['authMembershipsUserPhone']); + $this->assertTrue($response['body']['authMembershipsUserName']); + $this->assertTrue($response['body']['authMembershipsMfa']); + + // Step 6: List memberships with privacy enabled - user details exposed + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + $membershipsByUser = []; + foreach ($response['body']['memberships'] as $membership) { + $membershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($user1Id, $membershipsByUser); + $this->assertSame($user1Id, $membershipsByUser[$user1Id]['userId']); + $this->assertSame($user1Name, $membershipsByUser[$user1Id]['userName']); + $this->assertSame($user1Email, $membershipsByUser[$user1Id]['userEmail']); + $this->assertSame($user1Phone, $membershipsByUser[$user1Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user1Id]['mfa']); + + $this->assertArrayHasKey($user2Id, $membershipsByUser); + $this->assertSame($user2Id, $membershipsByUser[$user2Id]['userId']); + $this->assertSame($user2Name, $membershipsByUser[$user2Id]['userName']); + $this->assertSame($user2Email, $membershipsByUser[$user2Id]['userEmail']); + $this->assertSame($user2Phone, $membershipsByUser[$user2Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user2Id]['mfa']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php new file mode 100644 index 0000000000..2d0e15a70f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php @@ -0,0 +1,68 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // "password" is the top entry in the common-passwords dictionary and is 8 chars (min length). + $commonPassword = 'football'; + + // Step 1: Disable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authPasswordDictionary']); + + // Step 2: Create user with common password - should succeed + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_off_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary Off User', + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $this->assertNotEmpty($user1['body']['$id']); + + // Step 3: Enable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authPasswordDictionary']); + + // Step 4: Creating another user with the common password must fail + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_on_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary On User', + ]); + $this->assertSame(400, $user2['headers']['status-code']); + + // Cleanup: disable policy + $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php new file mode 100644 index 0000000000..c2dfd7be5e --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php @@ -0,0 +1,152 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Enable password history policy with limit 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authPasswordHistory']); + + $firstPassword = 'firstpassword'; + $secondPassword = 'secondpassword'; + $thirdPassword = 'thirdpassword'; + $fourthPassword = 'fourthpassword'; + + // Step 2: Sign up user with firstpassword (policy on, so signup populates history) + $email = 'history_' . uniqid() . '@localhost.test'; + $userId = ID::unique(); + + $account = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => $userId, + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'History User', + ]); + $this->assertSame(201, $account['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $firstPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + // Change password: first -> second + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $secondPassword, + 'oldPassword' => $firstPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Change password: second -> third + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $thirdPassword, + 'oldPassword' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Attempt to reuse each of the 3 previous passwords - all should fail + foreach ([$firstPassword, $secondPassword, $thirdPassword] as $reused) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $reused, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(400, $response['headers']['status-code'], 'Reusing password "' . $reused . '" should be blocked by history policy'); + $this->assertSame('password_recently_used', $response['body']['type']); + } + + // Step 4: Setting fourthpassword succeeds + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $fourthPassword, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Verify the new password works by signing in again + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fourthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + + // Step 5: Disable password history policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + // Step 6: With policy off, reusing any previous password should succeed, as should setting a brand new one. + // oldPassword must match current password, so walk through each previous password sequentially. + $fifthPassword = 'fifthpassword'; + $chain = [ + [$fourthPassword, $firstPassword], + [$firstPassword, $secondPassword], + [$secondPassword, $thirdPassword], + [$thirdPassword, $fourthPassword], + [$fourthPassword, $fifthPassword], + ]; + + foreach ($chain as [$current, $next]) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $next, + 'oldPassword' => $current, + ]); + $this->assertSame(200, $response['headers']['status-code'], 'Changing password from "' . $current . '" to "' . $next . '" should succeed with history policy disabled'); + } + + // Verify the final password works by signing in + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fifthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php new file mode 100644 index 0000000000..3284fed16f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php @@ -0,0 +1,104 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $setPersonalData = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authPersonalDataCheck']); + }; + + $buildCases = function (): array { + $suffix = \uniqid(); + $userId = 'personaluser' . $suffix; + $emailLocal = 'personalmail' . $suffix; + $email = $emailLocal . '@localhost.test'; + $name = 'Personalname' . $suffix; + $phone = '+12025550' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT); + + return [ + 'userId' => [ + 'userId' => $userId, + 'email' => 'safe_' . $suffix . '@localhost.test', + 'phone' => '+12025559' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => $userId . 'extra', + ], + 'email' => [ + 'userId' => 'safeid' . $suffix, + 'email' => $email, + 'phone' => '+12025558' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => 'prefix_' . $emailLocal . '_suffix', + ], + 'name' => [ + 'userId' => 'safeid2' . $suffix, + 'email' => 'safename_' . $suffix . '@localhost.test', + 'phone' => '+12025557' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => $name, + 'password' => 'prefix' . $name . 'xyz', + ], + 'phone' => [ + 'userId' => 'safeid3' . $suffix, + 'email' => 'safephone_' . $suffix . '@localhost.test', + 'phone' => $phone, + 'name' => 'Safe Name', + 'password' => 'prefix' . \str_replace('+', '', $phone) . 'xyz', + ], + ]; + }; + + $createUser = function (array $params) use ($serverHeaders): array { + return $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => $params['userId'], + 'email' => $params['email'], + 'phone' => $params['phone'], + 'password' => $params['password'], + 'name' => $params['name'], + ]); + }; + + // Step 1: Enable password personal data policy + $setPersonalData(true); + + // Step 2: Each of the four personal-data fields in the password must block user creation + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(400, $response['headers']['status-code'], 'Password containing ' . $field . ' should be rejected'); + $this->assertSame('password_personal_data', $response['body']['type']); + } + + // Step 3: Disable password personal data policy + $setPersonalData(false); + + // Step 4: The same categories of passwords should now be accepted (fresh data to avoid uniqueness conflicts) + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(201, $response['headers']['status-code'], 'Password containing ' . $field . ' should be accepted with policy disabled'); + $this->assertSame($params['userId'], $response['body']['$id']); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php new file mode 100644 index 0000000000..1500a1dcfa --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php @@ -0,0 +1,121 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + $password = 'password1234'; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setSessionAlert = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authSessionAlerts']); + }; + + $createUser = function (string $email) use ($serverHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Alert User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $createSession = function (string $email) use ($publicHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $countEmailsTo = function (string $address): int { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $count = 0; + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $count++; + } + } + } + return $count; + }; + + $assertEmailCountStays = function (string $address, int $expected, int $seconds) use ($countEmailsTo): void { + $deadline = \microtime(true) + $seconds; + while (\microtime(true) < $deadline) { + $this->assertSame($expected, $countEmailsTo($address), 'Unexpected email count for ' . $address); + \usleep(500_000); + } + }; + + // Step 1: Disable session alerts + $setSessionAlert(false); + + // Step 2: Create user1 and two sessions + $user1Email = 'alert1_' . uniqid() . '@localhost.test'; + $createUser($user1Email); + $createSession($user1Email); + $createSession($user1Email); + + // Step 3: No alert should arrive in the next 10 seconds + $assertEmailCountStays($user1Email, 0, 10); + + // Step 4: Enable session alerts + $setSessionAlert(true); + + // Step 5: Create user2 and one session + $user2Email = 'alert2_' . uniqid() . '@localhost.test'; + $createUser($user2Email); + $createSession($user2Email); + + // Step 6: First session never alerts, so nothing arrives in 10 seconds + $assertEmailCountStays($user2Email, 0, 10); + + // Step 7: Create the second session for user2 + $createSession($user2Email); + + // Step 8: Session alert email should eventually arrive + $this->assertEventually(function () use ($countEmailsTo, $user2Email) { + $this->assertSame(1, $countEmailsTo($user2Email)); + }, 15_000, 500); + + // Step 9: Disable session alerts + $setSessionAlert(false); + + // Step 10: Create the third session for user2 + $createSession($user2Email); + + // Step 11: No additional alert email should arrive in 10 seconds + $assertEmailCountStays($user2Email, 1, 10); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php new file mode 100644 index 0000000000..71562f52a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -0,0 +1,92 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setDuration = function (int $seconds) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $serverHeaders, [ + 'duration' => $seconds, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($seconds, $response['body']['authDuration']); + }; + + // Step 1: Set session duration to 5 seconds + $setDuration(5); + + // Step 2: Create user and a session + $email = 'duration_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Duration User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $accountHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Poll until the 5s TTL elapses - session should expire + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Raise duration to 10s - same session should still not be usable + $setDuration(10); + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + + // Step 5: Set duration to 1 year + $setDuration(31536000); + + // Step 6: Same session should still not be usable + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php new file mode 100644 index 0000000000..c9de2be9a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php @@ -0,0 +1,119 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setInvalidation = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authInvalidateSessions']); + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + // Step 1: Disable session invalidation + $setInvalidation(false); + + // Step 2: Create user and two sessions + $email = 'invalidation_' . uniqid() . '@localhost.test'; + $firstPassword = 'firstpassword'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'Invalidation User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + $login = function (string $password) use ($publicHeaders, $email, $projectId): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $projectId]; + }; + + $session1 = $login($firstPassword); + $session2 = $login($firstPassword); + + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + + // Step 3: Change password while invalidation is disabled - both sessions survive + $secondPassword = 'secondpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Enable session invalidation + $setInvalidation(true); + + // Step 5: Change password - both sessions should be invalidated + $thirdPassword = 'thirdpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 6: Disable session invalidation again + $setInvalidation(false); + + // Step 7: Previously-invalidated sessions stay dead + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php new file mode 100644 index 0000000000..295418a974 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -0,0 +1,122 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $email = 'session_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Create user (via API key so signup rules don't interfere) + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + + $login = function () use ($publicHeaders, $email, $password): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $this->getProject()['$id']]; + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + $setSessionLimit = function (?int $total) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $serverHeaders, [ + 'total' => $total, + ]); + $this->assertSame(200, $response['headers']['status-code']); + }; + + // Step 1: Session limit = 1 + $setSessionLimit(1); + + $session1 = $login(); + $this->assertEventually(function () use ($getAccount, $session1) { + $response = $getAccount($session1); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // New session pushes old one out + $session2 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + + // Step 2: Session limit = 2 + $setSessionLimit(2); + + $session3 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + + // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain + $session4 = $login(); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + + // Step 4: Disable session limit, create 5 new sessions, all should remain usable + $setSessionLimit(null); + + $newSessions = []; + for ($i = 0; $i < 5; $i++) { + $newSessions[] = $login(); + } + + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php new file mode 100644 index 0000000000..5ddcd8aaa1 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php @@ -0,0 +1,87 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $signupHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $signup = function () use ($signupHeaders): array { + return $this->client->call(Client::METHOD_POST, '/account', $signupHeaders, [ + 'userId' => ID::unique(), + 'email' => 'limit_' . uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Limit User', + ]); + }; + + // Step 1: Set user limit to 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authLimit']); + + // Create 3 users - all should succeed + for ($i = 1; $i <= 3; $i++) { + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code'], 'User ' . $i . ' should be created under limit of 3'); + } + + // User 4 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 2: Raise user limit to 4 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 4, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(4, $response['body']['authLimit']); + + // User 4 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + + // User 5 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 3: Remove user limit (null -> stored as 0 -> unlimited) + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + // User 5 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php new file mode 100644 index 0000000000..4bdf073e19 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -0,0 +1,1133 @@ +updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusDisable(): void + { + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + $response = $this->updateSMTP(enabled: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusEnableIdempotent(): void + { + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['smtpEnabled']); + + $second = $this->updateSMTP(enabled: true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusDisableIdempotent(): void + { + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['smtpEnabled']); + + $second = $this->updateSMTP(enabled: false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + enabled: true, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPStatusWithoutAuthentication(): void + { + $response = $this->updateSMTP(enabled: true, authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // Update SMTP tests + + public function testUpdateSMTPCredentials(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPWithOptionalReplyTo(): void + { + $response = $this->updateSMTP( + senderName: 'Full Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'reply@example.com', + replyToName: 'Full Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Full Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('reply@example.com', $response['body']['smtpReplyToEmail']); + $this->assertSame('Full Reply', $response['body']['smtpReplyToName']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPOverwritesPreviousSettings(): void + { + $this->updateSMTP( + senderName: 'First Sender', + senderEmail: 'first@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->updateSMTP( + senderName: 'Second Sender', + senderEmail: 'second@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Second Sender', $response['body']['smtpSenderName']); + $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnablesSMTP(): void + { + // Ensure SMTP is disabled + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPWithoutAuthentication(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'not-an-email', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderName(): void + { + $response = $this->updateSMTP( + senderName: '', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: '', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptyHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: '', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'https://myhost.com/v1', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidReplyToEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: 'invalid', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPSenderNameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'A', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('A', $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPSenderNameMaxLength(): void + { + $name = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: $name, + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPSenderNameTooLong(): void + { + $response = $this->updateSMTP( + senderName: str_repeat('a', 257), + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'u', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('u', $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPUsernameMaxLength(): void + { + $username = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: $username, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($username, $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPUsernameTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: 'p', + ); + + $this->assertSame(200, $response['headers']['status-code']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPPasswordMaxLength(): void + { + $password = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: $password, + ); + + $this->assertSame(200, $response['headers']['status-code']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPPasswordTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPWithoutSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSecure']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPInvalidConnectionEnabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPInvalidConnectionDisabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('Test', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + } + + public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy client sends `replyTo` (not `replyToEmail`). Request filter maps it. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + $headers, + [ + 'enabled' => true, + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@example.com', + 'host' => 'maildev', + 'port' => 1025, + 'replyTo' => 'legacy-reply@example.com', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Legacy Sender', $response['body']['smtpSenderName']); + $this->assertSame('legacy-sender@example.com', $response['body']['smtpSenderEmail']); + + // Response filter must expose smtpReplyTo and strip smtpReplyToEmail / smtpReplyToName. + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToName', $response['body']); + $this->assertSame('legacy-reply@example.com', $response['body']['smtpReplyTo']); + + // Sanity-check: a modern (non-legacy) read sees the new field names. + $modern = $this->updateSMTP(enabled: true); + $this->assertArrayHasKey('smtpReplyToEmail', $modern['body']); + $this->assertSame('legacy-reply@example.com', $modern['body']['smtpReplyToEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestLegacyInlineParams(): void + { + // Seed the project with a distinct SMTP config so we can prove the + // inline (1.9.1-style) params take precedence over project config. + $this->updateSMTP( + senderName: 'Project Sender', + senderEmail: 'project-sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'project-reply@example.com', + replyToName: 'Project Reply', + enabled: false, + ); + + $recipient = 'legacy-smtp-' . \uniqid() . '@appwrite.io'; + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_POST, + '/project/smtp/tests', + $headers, + [ + 'emails' => [$recipient], + 'senderName' => 'Inline Legacy Sender', + 'senderEmail' => 'inline-legacy@appwrite.io', + 'replyTo' => 'inline-legacy-reply@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Verify the email was sent using the inline params (not project SMTP). + $email = $this->getLastEmailByAddress($recipient, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame('inline-legacy@appwrite.io', $email['from'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['from'][0]['name']); + $this->assertSame('inline-legacy-reply@appwrite.io', $email['replyTo'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['replyTo'][0]['name']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPBackwardsCompatibilityDisable(): void + { + // First enable SMTP + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Use the deprecated enabled=false parameter to disable + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPRequiredFieldsOptionalAfterConfigured(): void + { + // Seed with a known configuration so required fields (host, port, senderEmail) are stored. + $this->updateSMTP( + senderName: 'Initial Sender', + senderEmail: 'initial@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Partial update: only update senderName, omitting host/port/senderEmail. + // Required fields should not be re-required because they are already stored. + $response = $this->updateSMTP(senderName: 'Updated Sender'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['smtpSenderName']); + $this->assertSame('initial@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPAllParamsOptionalAfterConfigured(): void + { + // Seed a configuration so all fields are stored. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Issue a PATCH with no params at all. Once previously configured, this must succeed. + $response = $this->updateSMTP(); + + $this->assertSame(200, $response['headers']['status-code']); + // Previously-set values are preserved + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnabledTrueWithInvalidCredentials(): void + { + // Explicitly enabling SMTP with unreachable host/port must throw. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPEnabledFalseWithInvalidCredentials(): void + { + // enabled=false means SMTP is not in use, so invalid credentials must be accepted. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithInvalidCredentialsDoesNotThrow(): void + { + // Ensure SMTP is currently disabled so we aren't enforcing validation on an enabled config. + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and invalid credentials, the request must not throw. + // SMTP remains disabled because the credentials could not be validated. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithValidCredentialsAutoEnables(): void + { + // Start from a disabled state. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and valid credentials, SMTP must be auto-enabled. + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Create SMTP test tests + + public function testCreateSMTPTest(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestMultipleRecipients(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([ + 'recipient1@example.com', + 'recipient2@example.com', + 'recipient3@example.com', + ]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestWhenSMTPDisabled(): void + { + // Ensure SMTP is disabled + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateSMTPTestWithoutAuthentication(): void + { + $response = $this->createSMTPTest(['recipient@example.com'], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateSMTPTestEmptyEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestInvalidEmail(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['not-an-email']); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestExceedsMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 11; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 10; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Integration tests + + public function testCreateSMTPTestEmailDelivery(): void + { + $senderName = 'SMTP Test Sender'; + $senderEmail = 'smtptest@appwrite.io'; + $replyToEmail = 'smtpreply@appwrite.io'; + $replyToName = 'SMTP Reply Team'; + $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; + + // Configure SMTP with reply-to and auth credentials + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + replyToEmail: $replyToEmail, + replyToName: $replyToName, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger test email + $response = $this->createSMTPTest([$recipientEmail]); + + $this->assertSame(204, $response['headers']['status-code']); + + // Verify email arrived via maildev + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); + $this->assertSame($replyToName, $email['replyTo'][0]['name']); + $this->assertSame('Custom SMTP email sample', $email['subject']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testMagicURLLoginUsesCustomSMTP(): void + { + $senderName = 'Custom Auth Mailer'; + $senderEmail = 'authmailer@appwrite.io'; + $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; + + // Configure custom SMTP with auth credentials + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger MagicURL login as a client (no auth headers needed) + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $recipientEmail, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + + // Verify the email arrived with custom SMTP sender details + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertStringContainsString('Login', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + // Helpers + + protected function updateSMTP( + ?string $senderName = null, + ?string $senderEmail = null, + ?string $host = null, + ?int $port = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + ?string $username = null, + ?string $password = null, + ?string $secure = null, + ?bool $enabled = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + $params = []; + + foreach (['senderName', 'senderEmail', 'host', 'port', 'replyToEmail', 'replyToName', 'username', 'password', 'secure', 'enabled'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + } + + /** + * @param array $emails + */ + protected function createSMTPTest(array $emails, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/smtp/tests', $headers, [ + 'emails' => $emails, + ]); + } +} diff --git a/tests/e2e/Services/Project/SMTPConsoleClientTest.php b/tests/e2e/Services/Project/SMTPConsoleClientTest.php new file mode 100644 index 0000000000..e5962c0960 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPConsoleClientTest.php @@ -0,0 +1,14 @@ +getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + // When locale is omitted, the fallback locale (en) is applied server-side. + $response = $this->getEmailTemplate('recovery'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('recovery', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateAllSupportedTypes(): void + { + $types = [ + 'verification', + 'magicSession', + 'recovery', + 'invitation', + 'mfaChallenge', + 'sessionAlert', + 'otpSession', + ]; + + foreach ($types as $type) { + $response = $this->getEmailTemplate($type, 'en'); + + $this->assertSame(200, $response['headers']['status-code'], "type={$type}"); + $this->assertSame($type, $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject'], "type={$type} must have default subject"); + $this->assertNotEmpty($response['body']['message'], "type={$type} must have default message"); + } + } + + public function testGetEmailTemplateNonDefaultLocale(): void + { + // Even a non-en locale that has no custom template must return defaults. + $response = $this->getEmailTemplate('verification', 'fr'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('fr', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateResponseModel(): void + { + $response = $this->getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testGetEmailTemplateInvalidType(): void + { + $response = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $response = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $response = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetEmailTemplateReturnsCustomValues(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'Custom invitation subject ' . \uniqid(); + $message = 'Custom invitation body ' . \uniqid(); + + $update = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: $subject, + message: $message, + senderName: 'Invitation Sender', + senderEmail: 'invitation@appwrite.io', + replyToEmail: 'reply-invitation@appwrite.io', + replyToName: 'Invitation Reply', + ); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('invitation', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('invitation', $get['body']['templateId']); + $this->assertSame('en', $get['body']['locale']); + $this->assertSame($subject, $get['body']['subject']); + $this->assertSame($message, $get['body']['message']); + $this->assertSame('Invitation Sender', $get['body']['senderName']); + $this->assertSame('invitation@appwrite.io', $get['body']['senderEmail']); + $this->assertSame('reply-invitation@appwrite.io', $get['body']['replyToEmail']); + $this->assertSame('Invitation Reply', $get['body']['replyToName']); + } + + public function testGetEmailTemplateCustomizationIsLocaleScoped(): void + { + $this->ensureSMTPEnabled(); + + $enSubject = 'EN only subject ' . \uniqid(); + $update = $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $enSubject, + message: 'EN only message', + ); + $this->assertSame(200, $update['headers']['status-code']); + + // Another locale must still return its defaults — not the en customization. + $other = $this->getEmailTemplate('mfaChallenge', 'de'); + $this->assertSame(200, $other['headers']['status-code']); + $this->assertSame('de', $other['body']['locale']); + $this->assertNotSame($enSubject, $other['body']['subject']); + } + + // Update email template tests + + public function testUpdateEmailTemplateRequiredFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Please verify your email', + message: 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertSame('Please verify your email', $response['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $response['body']['message']); + } + + public function testUpdateEmailTemplateAllFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: 'Password reset', + message: 'Reset your password', + senderName: 'Security Team', + senderEmail: 'security@appwrite.io', + replyToEmail: 'noreply@appwrite.io', + replyToName: 'No Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Password reset', $response['body']['subject']); + $this->assertSame('Reset your password', $response['body']['message']); + $this->assertSame('Security Team', $response['body']['senderName']); + $this->assertSame('security@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('noreply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('No Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $this->ensureSMTPEnabled(); + + // Omit locale entirely; server falls back to `en`. + $response = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: 'Session alert', + message: 'Someone signed in', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('sessionAlert', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateOverwritesPrevious(): void + { + $this->ensureSMTPEnabled(); + + $first = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'First subject', + message: 'First body', + ); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'Second subject', + message: 'Second body', + ); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second subject', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second subject', $get['body']['subject']); + $this->assertSame('Second body', $get['body']['message']); + } + + public function testUpdateEmailTemplatePartialAfterSeed(): void + { + $this->ensureSMTPEnabled(); + + // Seed a fully configured template. + $seed = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: 'Magic subject', + message: 'Magic body', + senderName: 'Magic Sender', + senderEmail: 'magic@appwrite.io', + replyToEmail: 'magic-reply@appwrite.io', + replyToName: 'Magic Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + // Once seeded, sending just one field is fine: previous subject/message persist. + $response = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + senderName: 'Updated Sender', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['senderName']); + $this->assertSame('Magic subject', $response['body']['subject']); + $this->assertSame('Magic body', $response['body']['message']); + $this->assertSame('magic@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('magic-reply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('Magic Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDifferentLocales(): void + { + $this->ensureSMTPEnabled(); + + $enUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'English subject', + message: 'English body', + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + $this->assertSame('English subject', $enUpdate['body']['subject']); + + $frUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'fr', + subject: 'Sujet francais', + message: 'Corps francais', + ); + $this->assertSame(200, $frUpdate['headers']['status-code']); + $this->assertSame('fr', $frUpdate['body']['locale']); + $this->assertSame('Sujet francais', $frUpdate['body']['subject']); + + // Locales remain independent. + $enGet = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('English subject', $enGet['body']['subject']); + + $frGet = $this->getEmailTemplate('invitation', 'fr'); + $this->assertSame('Sujet francais', $frGet['body']['subject']); + } + + public function testUpdateEmailTemplateResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Model check subject', + message: 'Model check body', + senderName: 'Sender', + senderEmail: 'sender@appwrite.io', + replyToEmail: 'reply@appwrite.io', + replyToName: 'Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testUpdateEmailTemplateSubjectMaxLength(): void + { + $this->ensureSMTPEnabled(); + + $subject = \str_repeat('a', 255); + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'Body', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($subject, $response['body']['subject']); + } + + public function testUpdateEmailTemplateSubjectTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: \str_repeat('a', 256), + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateSenderNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // senderName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + } + + public function testUpdateEmailTemplateReplyToNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // replyToName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateSenderNameTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: \str_repeat('a', 256), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'notATemplate', + locale: 'en', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidLocale(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'not-a-locale', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubjectOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'recovery'/'de' was never customized, so there is no persisted subject + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'de', + subject: null, + message: 'Body only', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateMissingMessageOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'invitation'/'es' was never customized, so there is no persisted message + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'es', + subject: 'Subject only', + message: null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateEmptySubject(): void + { + $this->ensureSMTPEnabled(); + + // Text(255) validator requires min length 1 — empty subject is rejected. + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: '', + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateEmptyMessage(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyToEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void + { + // Custom templates only make sense alongside a custom SMTP configuration. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + ['enabled' => false], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + try { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Should be blocked', + message: 'Should be blocked', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertStringContainsStringIgnoringCase('SMTP', $response['body']['message']); + } finally { + $this->ensureSMTPEnabled(); + } + } + + // Backwards compatibility (x-appwrite-response-format: 1.9.1) + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $response = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + ); + + $this->assertSame(200, $response['headers']['status-code']); + // The 1.9.1 response filter renames templateId -> type and strips replyToName. + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('verification', $response['body']['type']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyRequestAndResponse(): void + { + $this->ensureSMTPEnabled(); + + // Legacy clients send `type` + `replyTo`; request filter maps both. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy subject', + 'message' => 'Legacy body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayHasKey('replyTo', $response['body']); + $this->assertArrayNotHasKey('replyToEmail', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('magicSession', $response['body']['type']); + $this->assertSame('Legacy subject', $response['body']['subject']); + $this->assertSame('Legacy body', $response['body']['message']); + $this->assertSame('Legacy Sender', $response['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('legacy-reply@appwrite.io', $response['body']['replyTo']); + + // Modern clients see the new field names for the exact same record. + $modern = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame('magicSession', $modern['body']['templateId']); + $this->assertSame('legacy-reply@appwrite.io', $modern['body']['replyToEmail']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Session alert integration + + public function testSessionAlertUsesCustomTemplatePerLocale(): void + { + $this->ensureSMTPEnabled(); + + // session-alerts lives under /projects (console scope), so it's driven with the + // root console session rather than the current test's project-scoped headers. + $alertsResponse = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], + ['enabled' => true], + ); + $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); + + $runId = \uniqid(); + $enSubject = "EN alert subject {$runId}"; + $enMessage = "EN alert body marker {$runId}"; + $skSubject = "SK alert subject {$runId}"; + $skMessage = "SK alert body marker {$runId}"; + + // Configure custom EN template via the default-locale path (omit `locale`). + $enUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: $enSubject, + message: $enMessage, + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + + // Configure custom SK template explicitly. + $skUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'sk', + subject: $skSubject, + message: $skMessage, + ); + $this->assertSame(200, $skUpdate['headers']['status-code']); + + // Matrix of request-time locales and the custom template each one must resolve to. + // `de` has no custom template stored, so it must fall back to the `en` custom template. + $cases = [ + ['requestLocale' => 'en', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => null, 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => 'sk', 'expectedSubject' => $skSubject, 'expectedMessageMarker' => $skMessage], + ['requestLocale' => 'de', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ]; + + foreach ($cases as $case) { + $localeLabel = $case['requestLocale'] ?? 'none'; + $email = "session-alert-{$runId}-{$localeLabel}@appwrite.io"; + $password = 'password123'; + + // Fresh user per case so the session count starts at zero. + $create = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session Alert ' . $localeLabel, + ]); + $this->assertSame(201, $create['headers']['status-code'], "create user ({$localeLabel})"); + + // First session must NOT trigger an alert (count === 1 returns early). + $first = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $first['headers']['status-code'], "first session ({$localeLabel})"); + + // Second session — this one triggers the alert, with the test's request locale. + $headers = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + if ($case['requestLocale'] !== null) { + $headers['x-appwrite-locale'] = $case['requestLocale']; + } + $second = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $headers, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $second['headers']['status-code'], "second session ({$localeLabel})"); + + // The custom subject is uniquely tagged per run, so matching it proves both + // that an alert was sent and that the correct locale template was resolved. + $received = $this->getLastEmailByAddress($email, function ($mail) use ($case) { + $this->assertSame($case['expectedSubject'], $mail['subject']); + }); + + $this->assertSame($case['expectedSubject'], $received['subject'], "subject ({$localeLabel})"); + $this->assertStringContainsString( + $case['expectedMessageMarker'], + $received['text'] . $received['html'], + "message marker ({$localeLabel})", + ); + } + } + + // Helpers + + protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); + } + + protected function updateEmailTemplate( + string $templateId, + ?string $locale = null, + ?string $subject = null, + ?string $message = null, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = ['templateId' => $templateId]; + + foreach (['locale', 'subject', 'message', 'senderName', 'senderEmail', 'replyToEmail', 'replyToName'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); + } + + protected function ensureSMTPEnabled(): void + { + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + } +} diff --git a/tests/e2e/Services/Project/TemplatesConsoleClientTest.php b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php new file mode 100644 index 0000000000..d5431074e3 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 967dd16fb3..ed72d9375c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -927,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 @@ -943,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']); /** @@ -1077,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']); @@ -1085,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}}', @@ -1108,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']); @@ -1175,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 @@ -1193,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', @@ -1211,6 +1252,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'alerts' => true, ]); @@ -1353,6 +1395,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 10, // Set session duration to 10 seconds ]); @@ -1420,6 +1463,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 600, // seconds ]); @@ -1440,6 +1484,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, ]); @@ -1496,6 +1541,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -1512,6 +1558,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -1830,6 +1877,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -1908,6 +1956,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -1939,24 +1988,13 @@ class ProjectsConsoleClientTest extends Scope 'region' => System::getEnv('_APP_REGION', 'default') ]); - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2028,6 +2066,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 10, ]); @@ -2040,25 +2079,13 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectWithAuthLimit(); $id = $data['projectId']; - /** - * Test for Failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 25, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** * Test for Success */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2132,6 +2159,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2392,6 +2420,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2449,6 +2478,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2462,6 +2492,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -2481,6 +2512,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2593,6 +2625,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 80d73b3bc0..5b04108f71 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -254,7 +254,7 @@ trait TeamsBaseClient $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertFalse($response['body']['mfa']); - $this->assertNotEmpty($response['body']['userId']); + $this->assertArrayHasKey('userId', $response['body']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']);