From 193beb76fe3ada995af20e5d1db43c36f05c2bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:50:07 +0200 Subject: [PATCH 001/123] add SMTP endpoints --- app/controllers/api/projects.php | 218 ------------------ .../Project/Http/Project/Labels/Update.php | 7 +- .../Http/Project/SMTP/Status/Update.php | 74 ++++++ .../Project/Http/Project/SMTP/Test/Create.php | 142 ++++++++++++ .../Project/Http/Project/SMTP/Update.php | 142 ++++++++++++ 5 files changed, 363 insertions(+), 220 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5b82e6c1a3..e964933686 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -3,7 +3,6 @@ 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; @@ -13,7 +12,6 @@ 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; @@ -24,8 +22,6 @@ 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; @@ -619,220 +615,6 @@ 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/sms/:type/:locale') ->desc('Get custom SMS template') ->groups(['api', 'projects']) 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..304d9dc8a6 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; @@ -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/SMTP/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php new file mode 100644 index 0000000000..0669f6344f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp/status') + ->desc('Update project SMTP status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTPStatus', + description: <<param('enabled', null, new Boolean(), 'SMTP status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $smtp = $project->getAttribute('smtp', []); + + $smtp['enabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php new file mode 100644 index 0000000000..a9a59cba85 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -0,0 +1,142 @@ +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('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->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.') + ->inject('response') + ->inject('project') + ->inject('queueForMails') + ->callback($this->action(...)); + } + + /** + * @param array $emails + */ + public function action( + array $emails, + Response $response, + Document $project, + Mail $queueForMails + ): void { + + $smtp = $project->getAttribute('smtp', []); + + if ($smtp['enabled'] !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); + } + + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyTo = $smtp['replyTo'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? ''; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + + if (empty($senderName)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); + } + + 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.'); + } + + $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(); + } +} 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..027ced5664 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP details') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', '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('senderName', '', new Text(255, 0), 'Name of the email sender') + ->param('senderEmail', '', 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') + ->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) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + string $senderName, + string $senderEmail, + string $replyTo, + string $host, + int $port, + string $username, + string $password, + string $secure, + ?bool $enabled, // Backwards compatibility + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Backwards compatibility + if (!\is_null($enabled) && $enabled === false) { + $smtp = $project->getAttribute('smtp', []); + + $smtp['enabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } + + // Validate SMTP settings + $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()); + } + + $smtp = [ + 'enabled' => true, + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'replyTo' => $replyTo, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + 'secure' => $secure, + ]; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} From 556ca10ed9f2774eb20d044c21a5e61be5651df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:52:38 +0200 Subject: [PATCH 002/123] Register endpoints --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index a2c94928e2..d63be56632 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -23,6 +23,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as C 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\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Test\Create as CreateSMTPTest; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -45,6 +48,11 @@ class Http extends Service $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); + // SMTP + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); + $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); From ffdfe4bbf802d4c48fe86a16906eccbffff0b1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:55:39 +0200 Subject: [PATCH 003/123] Add new tests --- tests/e2e/Services/Project/SMTPBase.php | 572 ++++++++++++++++++ .../Project/SMTPConsoleClientTest.php | 14 + .../Services/Project/SMTPCustomServerTest.php | 14 + 3 files changed, 600 insertions(+) create mode 100644 tests/e2e/Services/Project/SMTPBase.php create mode 100644 tests/e2e/Services/Project/SMTPConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/SMTPCustomServerTest.php diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php new file mode 100644 index 0000000000..7089162e39 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -0,0 +1,572 @@ +updateSMTPStatus(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusDisable(): void + { + $this->updateSMTPStatus(true); + + $response = $this->updateSMTPStatus(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->updateSMTPStatus(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['smtpEnabled']); + + $second = $this->updateSMTPStatus(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusDisableIdempotent(): void + { + $first = $this->updateSMTPStatus(false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['smtpEnabled']); + + $second = $this->updateSMTPStatus(false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusResponseModel(): void + { + $response = $this->updateSMTPStatus(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('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusWithoutAuthentication(): void + { + $response = $this->updateSMTPStatus(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // Update SMTP tests + + public function testUpdateSMTP(): 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->updateSMTPStatus(false); + } + + public function testUpdateSMTPWithAllOptionalFields(): void + { + $response = $this->updateSMTP( + senderName: 'Full Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyTo: 'reply@example.com', + username: 'smtpuser', + password: 'smtppass', + secure: '', + ); + + $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']['smtpReplyTo']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + $this->assertSame('smtpuser', $response['body']['smtpUsername']); + $this->assertSame('smtppass', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(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->updateSMTPStatus(false); + } + + public function testUpdateSMTPEnablesSMTP(): void + { + // Ensure SMTP is disabled + $this->updateSMTPStatus(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->updateSMTPStatus(false); + } + + public function testUpdateSMTPResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $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('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTPStatus(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: 'not a valid host!@#', + 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, + replyTo: '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 testUpdateSMTPValidSecureTLS(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSecure']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPInvalidConnectionRefused(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPBackwardsCompatibilityDisable(): void + { + // First enable SMTP + $this->updateSMTPStatus(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']); + } + + // 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->updateSMTPStatus(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->updateSMTPStatus(false); + } + + public function testCreateSMTPTestWhenSMTPDisabled(): void + { + // Ensure SMTP is disabled + $this->updateSMTPStatus(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(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTPStatus(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->updateSMTPStatus(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->updateSMTPStatus(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->updateSMTPStatus(false); + } + + // Helpers + + protected function updateSMTPStatus(bool $enabled, 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_PATCH, '/project/smtp/status', $headers, [ + 'enabled' => $enabled, + ]); + } + + protected function updateSMTP( + string $senderName = '', + string $senderEmail = '', + string $host = '', + int $port = 587, + string $replyTo = '', + string $username = '', + string $password = '', + string $secure = '', + ?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 = [ + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'host' => $host, + 'port' => $port, + 'replyTo' => $replyTo, + 'username' => $username, + 'password' => $password, + 'secure' => $secure, + ]; + + if (!\is_null($enabled)) { + $params['enabled'] = $enabled; + } + + 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 @@ + Date: Tue, 14 Apr 2026 17:01:46 +0200 Subject: [PATCH 004/123] Fix bugs --- .../Modules/Project/Http/Project/SMTP/Test/Create.php | 8 +++++--- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php index a9a59cba85..fb93806983 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -59,6 +59,7 @@ class Create extends Action ->inject('response') ->inject('project') ->inject('queueForMails') + ->inject('plan') ->callback($this->action(...)); } @@ -69,7 +70,8 @@ class Create extends Action array $emails, Response $response, Document $project, - Mail $queueForMails + Mail $queueForMails, + array $plan ): void { $smtp = $project->getAttribute('smtp', []); @@ -106,7 +108,7 @@ class Create extends Action $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); + $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); $template ->setParam('{{from}}', "{$senderName} ({$senderEmail})") ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") @@ -130,7 +132,7 @@ class Create extends Action ->setSmtpSenderName($senderName) ->setRecipient($email) ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') + ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl') ->setBody($template->render()) ->setVariables([]) ->setSubject($subject) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 027ced5664..43bedb87c2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -99,6 +99,8 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); $response->dynamic($project, Response::MODEL_PROJECT); + + return; } // Validate SMTP settings From 56bcc0d09f15439726f04244b33b43104ee98075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:01:56 +0200 Subject: [PATCH 005/123] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 32 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 7089162e39..ee341e583f 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -117,7 +117,6 @@ trait SMTPBase replyTo: 'reply@example.com', username: 'smtpuser', password: 'smtppass', - secure: '', ); $this->assertSame(200, $response['headers']['status-code']); @@ -302,14 +301,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); } - public function testUpdateSMTPValidSecureTLS(): void + public function testUpdateSMTPWithoutSecure(): void { $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - secure: '', ); $this->assertSame(200, $response['headers']['status-code']); @@ -517,10 +515,10 @@ trait SMTPBase string $senderEmail = '', string $host = '', int $port = 587, - string $replyTo = '', - string $username = '', - string $password = '', - string $secure = '', + ?string $replyTo = null, + ?string $username = null, + ?string $password = null, + ?string $secure = null, ?bool $enabled = null, bool $authenticated = true, ): mixed { @@ -538,12 +536,24 @@ trait SMTPBase 'senderEmail' => $senderEmail, 'host' => $host, 'port' => $port, - 'replyTo' => $replyTo, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, ]; + if (!\is_null($replyTo)) { + $params['replyTo'] = $replyTo; + } + + if (!\is_null($username)) { + $params['username'] = $username; + } + + if (!\is_null($password)) { + $params['password'] = $password; + } + + if (!\is_null($secure)) { + $params['secure'] = $secure; + } + if (!\is_null($enabled)) { $params['enabled'] = $enabled; } From d7f8ca3f0146db1a8f5285563be1c401bd58e11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:04:22 +0200 Subject: [PATCH 006/123] Improve endpoint quality --- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 43bedb87c2..28c4929694 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -58,13 +58,13 @@ class Update extends Action ) ], )) - ->param('senderName', '', new Text(255, 0), 'Name of the email sender') + ->param('senderName', '', new Text(256), 'Name of the email sender') ->param('senderEmail', '', 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') - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) + ->param('username', '', new Text(256), 'SMTP server username', true) + ->param('password', '', new Text(256), 'SMTP server password', true) ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility ->inject('response') From 9d6428d5d52b02d062f61e983c8df14813484811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:06:57 +0200 Subject: [PATCH 007/123] Improve tests --- tests/e2e/Services/Project/SMTPBase.php | 176 +++++++++++++++++++++++- 1 file changed, 170 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index ee341e583f..7eee0ccaa1 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -107,7 +107,7 @@ trait SMTPBase $this->updateSMTPStatus(false); } - public function testUpdateSMTPWithAllOptionalFields(): void + public function testUpdateSMTPWithOptionalReplyTo(): void { $response = $this->updateSMTP( senderName: 'Full Sender', @@ -115,8 +115,6 @@ trait SMTPBase host: 'maildev', port: 1025, replyTo: 'reply@example.com', - username: 'smtpuser', - password: 'smtppass', ); $this->assertSame(200, $response['headers']['status-code']); @@ -126,8 +124,6 @@ trait SMTPBase $this->assertSame('reply@example.com', $response['body']['smtpReplyTo']); $this->assertSame('maildev', $response['body']['smtpHost']); $this->assertSame(1025, $response['body']['smtpPort']); - $this->assertSame('smtpuser', $response['body']['smtpUsername']); - $this->assertSame('smtppass', $response['body']['smtpPassword']); // Cleanup $this->updateSMTPStatus(false); @@ -301,6 +297,173 @@ trait SMTPBase $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->updateSMTPStatus(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->updateSMTPStatus(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->updateSMTPStatus(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->updateSMTPStatus(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']); + $this->assertSame('p', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(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']); + $this->assertSame($password, $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(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( @@ -421,7 +584,8 @@ trait SMTPBase $response = $this->createSMTPTest([]); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); // Cleanup $this->updateSMTPStatus(false); From a3a8ad88fe5e0f3833c996bb164870a618c47ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:07:06 +0200 Subject: [PATCH 008/123] formatting fix --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index d63be56632..54787c2782 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -23,10 +23,10 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as C 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\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Test\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; -use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; 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; From b5e46c1a60a5eae54e5f916b6b347a4b501415e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:09:37 +0200 Subject: [PATCH 009/123] E2E integration tests --- tests/e2e/Services/Project/SMTPBase.php | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 7eee0ccaa1..2f3762cfa8 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; trait SMTPBase { @@ -656,6 +657,91 @@ trait SMTPBase $this->updateSMTPStatus(false); } + // Integration tests + + public function testCreateSMTPTestEmailDelivery(): void + { + $senderName = 'SMTP Test Sender'; + $senderEmail = 'smtptest@appwrite.io'; + $replyToEmail = 'smtpreply@appwrite.io'; + $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; + + // Configure SMTP with replyTo + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + replyTo: $replyToEmail, + ); + + $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($senderName, $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->updateSMTPStatus(false); + } + + public function testMagicURLLoginUsesCustomSMTP(): void + { + $senderName = 'Custom Auth Mailer'; + $senderEmail = 'authmailer@appwrite.io'; + $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; + + // Configure custom SMTP + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + ); + + $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->updateSMTPStatus(false); + } + // Helpers protected function updateSMTPStatus(bool $enabled, bool $authenticated = true): mixed From 905d2a8eaa69bc5810fe3f914f1ec14d459aa6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:20:03 +0200 Subject: [PATCH 010/123] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 2f3762cfa8..da1325fac3 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -666,13 +666,15 @@ trait SMTPBase $replyToEmail = 'smtpreply@appwrite.io'; $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; - // Configure SMTP with replyTo + // Configure SMTP with replyTo and auth credentials $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', port: 1025, replyTo: $replyToEmail, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); @@ -706,12 +708,14 @@ trait SMTPBase $senderEmail = 'authmailer@appwrite.io'; $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; - // Configure custom SMTP + // Configure custom SMTP with auth credentials $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, - host: 'maildev', - port: 1025, + host: $smtpHost, + port: $smtpPort, + username: $smtpUsername, + password: $smtpPassword, ); $this->assertSame(200, $response['headers']['status-code']); From d3dcbfe567b8e7ec719277af9df3e43f1ef05b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:20:22 +0200 Subject: [PATCH 011/123] Leftover chaneges, --- tests/e2e/Services/Project/SMTPBase.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index da1325fac3..da5be3869d 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -712,10 +712,10 @@ trait SMTPBase $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, - host: $smtpHost, - port: $smtpPort, - username: $smtpUsername, - password: $smtpPassword, + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); From 732538504dde3e6e3b6a9d31b1d5c5f73c37f93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:42:26 +0200 Subject: [PATCH 012/123] Fix backwards compatibility --- .../Project/Http/Project/SMTP/Test/Create.php | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php index fb93806983..e1d4b3f847 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -17,6 +17,10 @@ use Utopia\Emails\Validator\Email; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; +use Utopia\Validator\Hostname; +use Utopia\Validator\Integer; +use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class Create extends Action { @@ -56,6 +60,14 @@ class Create extends Action contentType: ContentType::NONE, )) ->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') @@ -68,26 +80,36 @@ class Create extends Action */ 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 + $hasInlineParams = !empty($paramHost); $smtp = $project->getAttribute('smtp', []); - if ($smtp['enabled'] !== true) { + 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.'); } - $senderName = $smtp['senderName'] ?? ''; - $senderEmail = $smtp['senderEmail'] ?? ''; - $replyTo = $smtp['replyTo'] ?? ''; - $host = $smtp['host'] ?? ''; - $port = $smtp['port'] ?? ''; - $username = $smtp['username'] ?? ''; - $password = $smtp['password'] ?? ''; - $secure = $smtp['secure'] ?? ''; + $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); + $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); + $replyTo = $paramReplyTo ?: ($smtp['replyTo'] ?? ''); + $host = $paramHost ?: ($smtp['host'] ?? ''); + $port = $paramPort ?? ($smtp['port'] ?? ''); + $username = $paramUsername ?: ($smtp['username'] ?? ''); + $password = $paramPassword ?: ($smtp['password'] ?? ''); + $secure = $paramSecure ?: ($smtp['secure'] ?? ''); if (empty($senderName)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); From db0a971384978b92e59f6cd4c96bac6e4a9af56d Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 11:43:53 +0530 Subject: [PATCH 013/123] feat(sites): add screenshot success telemetry to usage stats --- app/init/constants.php | 6 ++ .../Modules/Functions/Workers/Screenshots.php | 55 ++++++++++++++++++- .../Platform/Modules/Sites/Http/Usage/Get.php | 22 ++++++-- .../Modules/Sites/Http/Usage/XList.php | 23 ++++++-- .../Utopia/Response/Model/UsageSite.php | 32 +++++++++++ .../Utopia/Response/Model/UsageSites.php | 32 +++++++++++ tests/e2e/General/UsageTest.php | 14 ++++- 7 files changed, 169 insertions(+), 15 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..57763df911 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -351,6 +351,8 @@ const METRIC_BUILDS_STORAGE = 'builds.storage'; const METRIC_BUILDS_COMPUTE = 'builds.compute'; const METRIC_BUILDS_COMPUTE_SUCCESS = 'builds.compute.success'; const METRIC_BUILDS_COMPUTE_FAILED = 'builds.compute.failed'; +const METRIC_SCREENSHOTS_SUCCESS = 'screenshots.success'; +const METRIC_SCREENSHOTS_FAILED = 'screenshots.failed'; const METRIC_BUILDS_MB_SECONDS = 'builds.mbSeconds'; const METRIC_EXECUTIONS = 'executions'; const METRIC_EXECUTIONS_COMPUTE = 'executions.compute'; @@ -363,6 +365,8 @@ const METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED = '{resourceType}.{resourceInternal const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE = '{resourceType}.{resourceInternalId}.builds.compute'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_SUCCESS = '{resourceType}.{resourceInternalId}.builds.compute.success'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_FAILED = '{resourceType}.{resourceInternalId}.builds.compute.failed'; +const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS = '{resourceType}.{resourceInternalId}.screenshots.success'; +const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED = '{resourceType}.{resourceInternalId}.screenshots.failed'; const METRIC_RESOURCE_TYPE_ID_BUILDS_MB_SECONDS = '{resourceType}.{resourceInternalId}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_ID_BUILDS = '{resourceType}.{resourceInternalId}.builds'; const METRIC_RESOURCE_TYPE_ID_BUILDS_STORAGE = '{resourceType}.{resourceInternalId}.builds.storage'; @@ -376,6 +380,8 @@ const METRIC_RESOURCE_TYPE_BUILDS_FAILED = '{resourceType}.builds.failed'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE = '{resourceType}.builds.compute'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_SUCCESS = '{resourceType}.builds.compute.success'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_FAILED = '{resourceType}.builds.compute.failed'; +const METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS = '{resourceType}.screenshots.success'; +const METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED = '{resourceType}.screenshots.failed'; const METRIC_RESOURCE_TYPE_BUILDS_MB_SECONDS = '{resourceType}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_BUILDS = '{resourceType}.builds'; const METRIC_RESOURCE_TYPE_BUILDS_STORAGE = '{resourceType}.builds.storage'; diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 065fe477eb..fa7e394710 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -3,9 +3,12 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; +use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Permission; use Appwrite\Role; +use Appwrite\Usage\Context; use Exception; use Utopia\Compression\Compression; use Utopia\Config\Config; @@ -43,6 +46,7 @@ class Screenshots extends Action ->inject('dbForProject') ->inject('project') ->inject('deviceForFiles') + ->inject('publisherForUsage') ->callback($this->action(...)); } @@ -52,7 +56,8 @@ class Screenshots extends Action Database $dbForPlatform, Database $dbForProject, Document $project, - Device $deviceForFiles + Device $deviceForFiles, + UsagePublisher $publisherForUsage ): void { Console::log('Screenshot action started'); @@ -257,6 +262,13 @@ class Screenshots extends Action 'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''), 'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''), ])); + + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_SUCCESS + ); } catch (\Throwable $th) { Console::warning("Screenshot failed to generate:"); Console::warning($th->getMessage()); @@ -265,10 +277,51 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_FAILED + ); + throw $th; } } + protected function publishUsage( + Document $project, + Document $site, + UsagePublisher $publisherForUsage, + string $metric + ): void { + [$resourceMetric, $resourceIdMetric] = match ($metric) { + METRIC_SCREENSHOTS_SUCCESS => [ + METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS, + METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS, + ], + METRIC_SCREENSHOTS_FAILED => [ + METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED, + METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED, + ], + default => throw new \InvalidArgumentException('Unknown screenshot metric: ' . $metric), + }; + + $usage = (new Context()) + ->addMetric($metric, 1) + ->addMetric(str_replace('{resourceType}', RESOURCE_TYPE_SITES, $resourceMetric), 1) + ->addMetric(str_replace( + ['{resourceType}', '{resourceInternalId}'], + [RESOURCE_TYPE_SITES, $site->getSequence()], + $resourceIdMetric + ), 1); + + $publisherForUsage->enqueue(new UsageMessage( + project: $project, + metrics: $usage->getMetrics(), + reduce: $usage->getReduce() + )); + } + protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs) { $deployment = $dbForProject->getDocument('deployments', $deploymentId); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a6768462d1..a1af4bd8fd 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -87,6 +87,8 @@ class Get extends Base str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_SUCCESS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED), + str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS), + str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_REQUESTS), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_INBOUND), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_OUTBOUND), @@ -139,6 +141,9 @@ class Get extends Base $buildsTimeTotal = $usage[$metrics[4]]['total'] ?? 0; $buildsTotal = $usage[$metrics[2]]['total'] ?? 0; + $screenshotsSuccessTotal = $usage[$metrics[11]]['total'] ?? 0; + $screenshotsFailedTotal = $usage[$metrics[12]]['total'] ?? 0; + $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; $response->dynamic(new Document([ 'range' => $range, 'deploymentsTotal' => $usage[$metrics[0]]['total'], @@ -153,9 +158,12 @@ class Get extends Base 'executionsMbSecondsTotal' => $usage[$metrics[8]]['total'], 'buildsSuccessTotal' => $usage[$metrics[9]]['total'], 'buildsFailedTotal' => $usage[$metrics[10]]['total'], - 'requestsTotal' => $usage[$metrics[11]]['total'], - 'inboundTotal' => $usage[$metrics[12]]['total'], - 'outboundTotal' => $usage[$metrics[13]]['total'], + 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, + 'screenshotsFailedTotal' => $screenshotsFailedTotal, + 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, + 'requestsTotal' => $usage[$metrics[13]]['total'], + 'inboundTotal' => $usage[$metrics[14]]['total'], + 'outboundTotal' => $usage[$metrics[15]]['total'], 'deployments' => $usage[$metrics[0]]['data'], 'deploymentsStorage' => $usage[$metrics[1]]['data'], 'builds' => $usage[$metrics[2]]['data'], @@ -167,9 +175,11 @@ class Get extends Base 'executionsMbSeconds' => $usage[$metrics[8]]['data'], 'buildsSuccess' => $usage[$metrics[9]]['data'], 'buildsFailed' => $usage[$metrics[10]]['data'], - 'requests' => $usage[$metrics[11]]['data'], - 'inbound' => $usage[$metrics[12]]['data'], - 'outbound' => $usage[$metrics[13]]['data'], + 'screenshotsSuccess' => $usage[$metrics[11]]['data'], + 'screenshotsFailed' => $usage[$metrics[12]]['data'], + 'requests' => $usage[$metrics[13]]['data'], + 'inbound' => $usage[$metrics[14]]['data'], + 'outbound' => $usage[$metrics[15]]['data'], ]), Response::MODEL_USAGE_SITE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index a90cb0cab9..3b3c759fd5 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -74,6 +74,8 @@ class XList extends Base str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_SUCCESS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_FAILED), + str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS), + str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED), METRIC_SITES_REQUESTS, METRIC_SITES_INBOUND, METRIC_SITES_OUTBOUND, @@ -122,6 +124,10 @@ class XList extends Base ]; } } + $screenshotsSuccessTotal = $usage[$metrics[12]]['total'] ?? 0; + $screenshotsFailedTotal = $usage[$metrics[13]]['total'] ?? 0; + $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; + $response->dynamic(new Document([ 'range' => $range, 'sitesTotal' => $usage[$metrics[0]]['total'], @@ -136,9 +142,12 @@ class XList extends Base 'executionsMbSecondsTotal' => $usage[$metrics[9]]['total'], 'buildsSuccessTotal' => $usage[$metrics[10]]['total'], 'buildsFailedTotal' => $usage[$metrics[11]]['total'], - 'requestsTotal' => $usage[$metrics[12]]['total'], - 'inboundTotal' => $usage[$metrics[13]]['total'], - 'outboundTotal' => $usage[$metrics[14]]['total'], + 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, + 'screenshotsFailedTotal' => $screenshotsFailedTotal, + 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, + 'requestsTotal' => $usage[$metrics[14]]['total'], + 'inboundTotal' => $usage[$metrics[15]]['total'], + 'outboundTotal' => $usage[$metrics[16]]['total'], 'sites' => $usage[$metrics[0]]['data'], 'deployments' => $usage[$metrics[1]]['data'], 'deploymentsStorage' => $usage[$metrics[2]]['data'], @@ -151,9 +160,11 @@ class XList extends Base 'executionsMbSeconds' => $usage[$metrics[9]]['data'], 'buildsSuccess' => $usage[$metrics[10]]['data'], 'buildsFailed' => $usage[$metrics[11]]['data'], - 'requests' => $usage[$metrics[12]]['data'], - 'inbound' => $usage[$metrics[13]]['data'], - 'outbound' => $usage[$metrics[14]]['data'], + 'screenshotsSuccess' => $usage[$metrics[12]]['data'], + 'screenshotsFailed' => $usage[$metrics[13]]['data'], + 'requests' => $usage[$metrics[14]]['data'], + 'inbound' => $usage[$metrics[15]]['data'], + 'outbound' => $usage[$metrics[16]]['data'], ]), Response::MODEL_USAGE_SITES); } } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSite.php b/src/Appwrite/Utopia/Response/Model/UsageSite.php index c45dc831cd..27828426d3 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSite.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSite.php @@ -49,6 +49,38 @@ class UsageSite extends UsageFunction 'example' => [], 'array' => true ]) + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSites.php b/src/Appwrite/Utopia/Response/Model/UsageSites.php index fea87c7718..241e08e6ba 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSites.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSites.php @@ -122,6 +122,38 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('deployments', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites deployment per period.', diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index f6eb963967..0d2c0a7ed7 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1675,11 +1675,14 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(30, count($response['body'])); + $this->assertEquals(35, count($response['body'])); $this->assertEquals('30d', $response['body']['range']); $this->assertIsArray($response['body']['deployments']); $this->assertEquals($deploymentsSuccess, $response['body']['buildsSuccessTotal']); $this->assertEquals($deploymentsFailed, $response['body']['buildsFailedTotal']); + $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); + $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); + $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); $this->assertIsArray($response['body']['deploymentsStorage']); $this->assertIsNumeric($response['body']['deploymentsStorageTotal']); $this->assertIsNumeric($response['body']['buildsMbSecondsTotal']); @@ -1692,6 +1695,8 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); + $this->assertIsArray($response['body']['screenshotsSuccess']); + $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); @@ -1709,7 +1714,7 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(31, count($response['body'])); + $this->assertEquals(36, count($response['body'])); $this->assertEquals($response['body']['range'], '30d'); $this->assertIsArray($response['body']['sites']); $this->assertIsArray($response['body']['deployments']); @@ -1722,6 +1727,11 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); + $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); + $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); + $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); + $this->assertIsArray($response['body']['screenshotsSuccess']); + $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); From 37540aa4f5448009525de189d1e4bda98440bb65 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 11:52:39 +0530 Subject: [PATCH 014/123] addressed comments --- .../Modules/Functions/Workers/Screenshots.php | 30 +++-- .../Utopia/Response/Model/UsageSite.php | 64 +++++----- .../Utopia/Response/Model/UsageSites.php | 116 +++++++++--------- 3 files changed, 107 insertions(+), 103 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index fa7e394710..dbd0e217c9 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -62,6 +62,7 @@ class Screenshots extends Action Console::log('Screenshot action started'); $payload = $message->getPayload() ?? []; + $screenshotCompleted = false; if (empty($payload)) { throw new \Exception('Missing payload'); @@ -262,13 +263,7 @@ class Screenshots extends Action 'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''), 'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''), ])); - - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_SUCCESS - ); + $screenshotCompleted = true; } catch (\Throwable $th) { Console::warning("Screenshot failed to generate:"); Console::warning($th->getMessage()); @@ -277,15 +272,24 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_FAILED - ); + if (!$screenshotCompleted) { + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_FAILED + ); + } throw $th; } + + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_SUCCESS + ); } protected function publishUsage( diff --git a/src/Appwrite/Utopia/Response/Model/UsageSite.php b/src/Appwrite/Utopia/Response/Model/UsageSite.php index 27828426d3..af41a34639 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSite.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSite.php @@ -10,6 +10,38 @@ class UsageSite extends UsageFunction { parent::__construct(); $this + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', @@ -49,38 +81,6 @@ class UsageSite extends UsageFunction 'example' => [], 'array' => true ]) - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSites.php b/src/Appwrite/Utopia/Response/Model/UsageSites.php index 241e08e6ba..94ee6e645f 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSites.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSites.php @@ -83,6 +83,36 @@ class UsageSites extends Model 'default' => 0, 'example' => 0, ]) + ->addRule('buildsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site builds.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('buildsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site builds.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', @@ -122,38 +152,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ->addRule('deployments', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites deployment per period.', @@ -168,18 +166,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('buildsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site builds.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('buildsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site builds.', - 'default' => 0, - 'example' => 0, - ]) ->addRule('builds', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites build per period.', @@ -208,6 +194,34 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('buildsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('buildsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('executions', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites execution per period.', @@ -229,20 +243,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('buildsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('buildsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ; } From 23233d0dcb2b9550e56d1cc2f6dc9cf47888aef5 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 11:59:02 +0530 Subject: [PATCH 015/123] add try catch --- .../Modules/Functions/Workers/Screenshots.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index dbd0e217c9..d1f2b0d0bf 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -273,12 +273,16 @@ class Screenshots extends Action $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); if (!$screenshotCompleted) { - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_FAILED - ); + try { + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_FAILED + ); + } catch (\Throwable) { + // Usage publish is best-effort; preserve the original screenshot exception. + } } throw $th; From 730c62bda60cbb32da196c01b01c7ac4d6c98c22 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 18:35:09 +0530 Subject: [PATCH 016/123] remove usage stats --- app/init/constants.php | 6 -- .../Modules/Functions/Workers/Screenshots.php | 74 ++++------------ .../Platform/Modules/Sites/Http/Usage/Get.php | 22 ++--- .../Modules/Sites/Http/Usage/XList.php | 23 ++--- .../Utopia/Response/Model/UsageSite.php | 32 ------- .../Utopia/Response/Model/UsageSites.php | 84 ++++++------------- tests/e2e/General/UsageTest.php | 14 +--- 7 files changed, 57 insertions(+), 198 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 57763df911..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -351,8 +351,6 @@ const METRIC_BUILDS_STORAGE = 'builds.storage'; const METRIC_BUILDS_COMPUTE = 'builds.compute'; const METRIC_BUILDS_COMPUTE_SUCCESS = 'builds.compute.success'; const METRIC_BUILDS_COMPUTE_FAILED = 'builds.compute.failed'; -const METRIC_SCREENSHOTS_SUCCESS = 'screenshots.success'; -const METRIC_SCREENSHOTS_FAILED = 'screenshots.failed'; const METRIC_BUILDS_MB_SECONDS = 'builds.mbSeconds'; const METRIC_EXECUTIONS = 'executions'; const METRIC_EXECUTIONS_COMPUTE = 'executions.compute'; @@ -365,8 +363,6 @@ const METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED = '{resourceType}.{resourceInternal const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE = '{resourceType}.{resourceInternalId}.builds.compute'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_SUCCESS = '{resourceType}.{resourceInternalId}.builds.compute.success'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_FAILED = '{resourceType}.{resourceInternalId}.builds.compute.failed'; -const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS = '{resourceType}.{resourceInternalId}.screenshots.success'; -const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED = '{resourceType}.{resourceInternalId}.screenshots.failed'; const METRIC_RESOURCE_TYPE_ID_BUILDS_MB_SECONDS = '{resourceType}.{resourceInternalId}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_ID_BUILDS = '{resourceType}.{resourceInternalId}.builds'; const METRIC_RESOURCE_TYPE_ID_BUILDS_STORAGE = '{resourceType}.{resourceInternalId}.builds.storage'; @@ -380,8 +376,6 @@ const METRIC_RESOURCE_TYPE_BUILDS_FAILED = '{resourceType}.builds.failed'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE = '{resourceType}.builds.compute'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_SUCCESS = '{resourceType}.builds.compute.success'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_FAILED = '{resourceType}.builds.compute.failed'; -const METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS = '{resourceType}.screenshots.success'; -const METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED = '{resourceType}.screenshots.failed'; const METRIC_RESOURCE_TYPE_BUILDS_MB_SECONDS = '{resourceType}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_BUILDS = '{resourceType}.builds'; const METRIC_RESOURCE_TYPE_BUILDS_STORAGE = '{resourceType}.builds.storage'; diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index bac72b993f..0cb42822b3 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -4,12 +4,9 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Message\Screenshot; -use Appwrite\Event\Message\Usage as UsageMessage; -use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Permission; use Appwrite\Role; -use Appwrite\Usage\Context; use Exception; use Utopia\Compression\Compression; use Utopia\Config\Config; @@ -23,6 +20,7 @@ use Utopia\Platform\Action; use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\System\System; +use Utopia\Telemetry\Adapter as Telemetry; use function Swoole\Coroutine\batch; @@ -47,7 +45,7 @@ class Screenshots extends Action ->inject('dbForProject') ->inject('project') ->inject('deviceForFiles') - ->inject('publisherForUsage') + ->inject('telemetry') ->callback($this->action(...)); } @@ -58,12 +56,11 @@ class Screenshots extends Action Database $dbForProject, Document $project, Device $deviceForFiles, - UsagePublisher $publisherForUsage + Telemetry $telemetry ): void { Console::log('Screenshot action started'); $payload = $message->getPayload() ?? []; - $screenshotCompleted = false; if (empty($payload)) { throw new \Exception('Missing payload'); @@ -266,7 +263,6 @@ class Screenshots extends Action 'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''), 'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''), ])); - $screenshotCompleted = true; } catch (\Throwable $th) { Console::warning("Screenshot failed to generate:"); Console::warning($th->getMessage()); @@ -275,62 +271,26 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); - if (!$screenshotCompleted) { - try { - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_FAILED - ); - } catch (\Throwable) { - // Usage publish is best-effort; preserve the original screenshot exception. - } - } + $this->recordTelemetry($telemetry, 'failure'); throw $th; } - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_SUCCESS - ); + $this->recordTelemetry($telemetry, 'success'); } - protected function publishUsage( - Document $project, - Document $site, - UsagePublisher $publisherForUsage, - string $metric - ): void { - [$resourceMetric, $resourceIdMetric] = match ($metric) { - METRIC_SCREENSHOTS_SUCCESS => [ - METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS, - METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS, - ], - METRIC_SCREENSHOTS_FAILED => [ - METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED, - METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED, - ], - default => throw new \InvalidArgumentException('Unknown screenshot metric: ' . $metric), - }; - - $usage = (new Context()) - ->addMetric($metric, 1) - ->addMetric(str_replace('{resourceType}', RESOURCE_TYPE_SITES, $resourceMetric), 1) - ->addMetric(str_replace( - ['{resourceType}', '{resourceInternalId}'], - [RESOURCE_TYPE_SITES, $site->getSequence()], - $resourceIdMetric - ), 1); - - $publisherForUsage->enqueue(new UsageMessage( - project: $project, - metrics: $usage->getMetrics(), - reduce: $usage->getReduce() - )); + protected function recordTelemetry(Telemetry $telemetry, string $result): void + { + try { + $telemetry + ->createCounter('worker.screenshots.capture') + ->add(1, [ + 'resourceType' => RESOURCE_TYPE_SITES, + 'result' => $result, + ]); + } catch (\Throwable) { + // Telemetry should never affect screenshot processing. + } } protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a1af4bd8fd..a6768462d1 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -87,8 +87,6 @@ class Get extends Base str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_SUCCESS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED), - str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS), - str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_REQUESTS), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_INBOUND), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_OUTBOUND), @@ -141,9 +139,6 @@ class Get extends Base $buildsTimeTotal = $usage[$metrics[4]]['total'] ?? 0; $buildsTotal = $usage[$metrics[2]]['total'] ?? 0; - $screenshotsSuccessTotal = $usage[$metrics[11]]['total'] ?? 0; - $screenshotsFailedTotal = $usage[$metrics[12]]['total'] ?? 0; - $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; $response->dynamic(new Document([ 'range' => $range, 'deploymentsTotal' => $usage[$metrics[0]]['total'], @@ -158,12 +153,9 @@ class Get extends Base 'executionsMbSecondsTotal' => $usage[$metrics[8]]['total'], 'buildsSuccessTotal' => $usage[$metrics[9]]['total'], 'buildsFailedTotal' => $usage[$metrics[10]]['total'], - 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, - 'screenshotsFailedTotal' => $screenshotsFailedTotal, - 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, - 'requestsTotal' => $usage[$metrics[13]]['total'], - 'inboundTotal' => $usage[$metrics[14]]['total'], - 'outboundTotal' => $usage[$metrics[15]]['total'], + 'requestsTotal' => $usage[$metrics[11]]['total'], + 'inboundTotal' => $usage[$metrics[12]]['total'], + 'outboundTotal' => $usage[$metrics[13]]['total'], 'deployments' => $usage[$metrics[0]]['data'], 'deploymentsStorage' => $usage[$metrics[1]]['data'], 'builds' => $usage[$metrics[2]]['data'], @@ -175,11 +167,9 @@ class Get extends Base 'executionsMbSeconds' => $usage[$metrics[8]]['data'], 'buildsSuccess' => $usage[$metrics[9]]['data'], 'buildsFailed' => $usage[$metrics[10]]['data'], - 'screenshotsSuccess' => $usage[$metrics[11]]['data'], - 'screenshotsFailed' => $usage[$metrics[12]]['data'], - 'requests' => $usage[$metrics[13]]['data'], - 'inbound' => $usage[$metrics[14]]['data'], - 'outbound' => $usage[$metrics[15]]['data'], + 'requests' => $usage[$metrics[11]]['data'], + 'inbound' => $usage[$metrics[12]]['data'], + 'outbound' => $usage[$metrics[13]]['data'], ]), Response::MODEL_USAGE_SITE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index 3b3c759fd5..a90cb0cab9 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -74,8 +74,6 @@ class XList extends Base str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_SUCCESS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_FAILED), - str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS), - str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED), METRIC_SITES_REQUESTS, METRIC_SITES_INBOUND, METRIC_SITES_OUTBOUND, @@ -124,10 +122,6 @@ class XList extends Base ]; } } - $screenshotsSuccessTotal = $usage[$metrics[12]]['total'] ?? 0; - $screenshotsFailedTotal = $usage[$metrics[13]]['total'] ?? 0; - $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; - $response->dynamic(new Document([ 'range' => $range, 'sitesTotal' => $usage[$metrics[0]]['total'], @@ -142,12 +136,9 @@ class XList extends Base 'executionsMbSecondsTotal' => $usage[$metrics[9]]['total'], 'buildsSuccessTotal' => $usage[$metrics[10]]['total'], 'buildsFailedTotal' => $usage[$metrics[11]]['total'], - 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, - 'screenshotsFailedTotal' => $screenshotsFailedTotal, - 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, - 'requestsTotal' => $usage[$metrics[14]]['total'], - 'inboundTotal' => $usage[$metrics[15]]['total'], - 'outboundTotal' => $usage[$metrics[16]]['total'], + 'requestsTotal' => $usage[$metrics[12]]['total'], + 'inboundTotal' => $usage[$metrics[13]]['total'], + 'outboundTotal' => $usage[$metrics[14]]['total'], 'sites' => $usage[$metrics[0]]['data'], 'deployments' => $usage[$metrics[1]]['data'], 'deploymentsStorage' => $usage[$metrics[2]]['data'], @@ -160,11 +151,9 @@ class XList extends Base 'executionsMbSeconds' => $usage[$metrics[9]]['data'], 'buildsSuccess' => $usage[$metrics[10]]['data'], 'buildsFailed' => $usage[$metrics[11]]['data'], - 'screenshotsSuccess' => $usage[$metrics[12]]['data'], - 'screenshotsFailed' => $usage[$metrics[13]]['data'], - 'requests' => $usage[$metrics[14]]['data'], - 'inbound' => $usage[$metrics[15]]['data'], - 'outbound' => $usage[$metrics[16]]['data'], + 'requests' => $usage[$metrics[12]]['data'], + 'inbound' => $usage[$metrics[13]]['data'], + 'outbound' => $usage[$metrics[14]]['data'], ]), Response::MODEL_USAGE_SITES); } } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSite.php b/src/Appwrite/Utopia/Response/Model/UsageSite.php index af41a34639..c45dc831cd 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSite.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSite.php @@ -10,38 +10,6 @@ class UsageSite extends UsageFunction { parent::__construct(); $this - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', diff --git a/src/Appwrite/Utopia/Response/Model/UsageSites.php b/src/Appwrite/Utopia/Response/Model/UsageSites.php index 94ee6e645f..fea87c7718 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSites.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSites.php @@ -83,36 +83,6 @@ class UsageSites extends Model 'default' => 0, 'example' => 0, ]) - ->addRule('buildsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site builds.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('buildsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site builds.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', @@ -166,6 +136,18 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('buildsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site builds.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('buildsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site builds.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('builds', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites build per period.', @@ -194,34 +176,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('buildsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('buildsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ->addRule('executions', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites execution per period.', @@ -243,6 +197,20 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('buildsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('buildsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ; } diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index 0d2c0a7ed7..f6eb963967 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1675,14 +1675,11 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(35, count($response['body'])); + $this->assertEquals(30, count($response['body'])); $this->assertEquals('30d', $response['body']['range']); $this->assertIsArray($response['body']['deployments']); $this->assertEquals($deploymentsSuccess, $response['body']['buildsSuccessTotal']); $this->assertEquals($deploymentsFailed, $response['body']['buildsFailedTotal']); - $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); - $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); - $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); $this->assertIsArray($response['body']['deploymentsStorage']); $this->assertIsNumeric($response['body']['deploymentsStorageTotal']); $this->assertIsNumeric($response['body']['buildsMbSecondsTotal']); @@ -1695,8 +1692,6 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); - $this->assertIsArray($response['body']['screenshotsSuccess']); - $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); @@ -1714,7 +1709,7 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(36, count($response['body'])); + $this->assertEquals(31, count($response['body'])); $this->assertEquals($response['body']['range'], '30d'); $this->assertIsArray($response['body']['sites']); $this->assertIsArray($response['body']['deployments']); @@ -1727,11 +1722,6 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); - $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); - $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); - $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); - $this->assertIsArray($response['body']['screenshotsSuccess']); - $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); From 7fe65eec5751116f86689f16efb62f5a8f501c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:23:31 +0200 Subject: [PATCH 017/123] Restruture endpoints --- .../Project/SMTP/{ => Credentials}/Update.php | 8 +- .../Project/SMTP/{Test => Tests}/Create.php | 2 +- .../Modules/Project/Services/Http.php | 6 +- tests/e2e/Services/Project/SMTPBase.php | 78 +++++++++---------- 4 files changed, 47 insertions(+), 47 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/{ => Credentials}/Update.php (95%) rename src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/{Test => Tests}/Create.php (99%) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php similarity index 95% rename from src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php index 28c4929694..9117554622 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp') + ->setHttpPath('/v1/project/smtp/credentials') ->httpAlias('/v1/projects/:projectId/smtp') ->desc('Update project SMTP details') ->groups(['api', 'project']) @@ -46,7 +46,7 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: 'smtp', - name: 'updateSMTP', + name: 'updateSMTPCredentials', description: <<addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // SMTP - $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index da5be3869d..51e2e5e8a9 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -87,9 +87,9 @@ trait SMTPBase // Update SMTP tests - public function testUpdateSMTP(): void + public function testUpdateSMTPCredentials(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -110,7 +110,7 @@ trait SMTPBase public function testUpdateSMTPWithOptionalReplyTo(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Full Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -132,14 +132,14 @@ trait SMTPBase public function testUpdateSMTPOverwritesPreviousSettings(): void { - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'First Sender', senderEmail: 'first@example.com', host: 'maildev', port: 1025, ); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Second Sender', senderEmail: 'second@example.com', host: 'maildev', @@ -159,7 +159,7 @@ trait SMTPBase // Ensure SMTP is disabled $this->updateSMTPStatus(false); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -175,7 +175,7 @@ trait SMTPBase public function testUpdateSMTPResponseModel(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -201,7 +201,7 @@ trait SMTPBase public function testUpdateSMTPWithoutAuthentication(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -214,7 +214,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSenderEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'not-an-email', host: 'maildev', @@ -226,7 +226,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderName(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: '', senderEmail: 'sender@example.com', host: 'maildev', @@ -238,7 +238,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: '', host: 'maildev', @@ -250,7 +250,7 @@ trait SMTPBase public function testUpdateSMTPEmptyHost(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: '', @@ -262,7 +262,7 @@ trait SMTPBase public function testUpdateSMTPInvalidHost(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'not a valid host!@#', @@ -274,7 +274,7 @@ trait SMTPBase public function testUpdateSMTPInvalidReplyToEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -287,7 +287,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSecure(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -300,7 +300,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'A', senderEmail: 'sender@example.com', host: 'maildev', @@ -317,7 +317,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMaxLength(): void { $name = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $name, senderEmail: 'sender@example.com', host: 'maildev', @@ -333,7 +333,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: str_repeat('a', 257), senderEmail: 'sender@example.com', host: 'maildev', @@ -345,7 +345,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -363,7 +363,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMaxLength(): void { $username = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -380,7 +380,7 @@ trait SMTPBase public function testUpdateSMTPUsernameTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -393,7 +393,7 @@ trait SMTPBase public function testUpdateSMTPUsernameEmpty(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -406,7 +406,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -424,7 +424,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMaxLength(): void { $password = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -441,7 +441,7 @@ trait SMTPBase public function testUpdateSMTPPasswordTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -454,7 +454,7 @@ trait SMTPBase public function testUpdateSMTPPasswordEmpty(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -467,7 +467,7 @@ trait SMTPBase public function testUpdateSMTPWithoutSecure(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -483,7 +483,7 @@ trait SMTPBase public function testUpdateSMTPInvalidConnectionRefused(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', @@ -500,7 +500,7 @@ trait SMTPBase $this->updateSMTPStatus(true); // Use the deprecated enabled=false parameter to disable - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -517,7 +517,7 @@ trait SMTPBase public function testCreateSMTPTest(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -536,7 +536,7 @@ trait SMTPBase public function testCreateSMTPTestMultipleRecipients(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -576,7 +576,7 @@ trait SMTPBase public function testCreateSMTPTestEmptyEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -595,7 +595,7 @@ trait SMTPBase public function testCreateSMTPTestInvalidEmail(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -613,7 +613,7 @@ trait SMTPBase public function testCreateSMTPTestExceedsMaxEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -636,7 +636,7 @@ trait SMTPBase public function testCreateSMTPTestMaxEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -667,7 +667,7 @@ trait SMTPBase $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; // Configure SMTP with replyTo and auth credentials - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -709,7 +709,7 @@ trait SMTPBase $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; // Configure custom SMTP with auth credentials - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -764,7 +764,7 @@ trait SMTPBase ]); } - protected function updateSMTP( + protected function updateSMTPCredentials( string $senderName = '', string $senderEmail = '', string $host = '', @@ -812,7 +812,7 @@ trait SMTPBase $params['enabled'] = $enabled; } - return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + return $this->client->call(Client::METHOD_PATCH, '/project/smtp/credentials', $headers, $params); } /** From 93b9500a95af55447e438c2b04fadfd2e2a172e2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Apr 2026 15:22:45 +0530 Subject: [PATCH 018/123] align it with cloud pattern --- .../Modules/Functions/Workers/Screenshots.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 0cb42822b3..8fed46aa00 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -21,6 +21,7 @@ use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; +use Utopia\Telemetry\Counter; use function Swoole\Coroutine\batch; @@ -67,6 +68,7 @@ class Screenshots extends Action } $screenshotMessage = Screenshot::fromArray($payload); + $counter = $telemetry->createCounter('worker.screenshots.capture'); Console::log('Site screenshot started'); @@ -271,23 +273,21 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); - $this->recordTelemetry($telemetry, 'failure'); + $this->recordTelemetry($counter, 'failure'); throw $th; } - $this->recordTelemetry($telemetry, 'success'); + $this->recordTelemetry($counter, 'success'); } - protected function recordTelemetry(Telemetry $telemetry, string $result): void + protected function recordTelemetry(Counter $counter, string $result): void { try { - $telemetry - ->createCounter('worker.screenshots.capture') - ->add(1, [ - 'resourceType' => RESOURCE_TYPE_SITES, - 'result' => $result, - ]); + $counter->add(1, [ + 'resourceType' => RESOURCE_TYPE_SITES, + 'result' => $result, + ]); } catch (\Throwable) { // Telemetry should never affect screenshot processing. } From 1a46fc200613fba8c71693645de8b36a81468666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:43:17 +0200 Subject: [PATCH 019/123] Move template APIs under project API --- app/controllers/api/projects.php | 219 ------------------ .../Http/Project/Templates/Email/Delete.php | 101 ++++++++ .../Http/Project/Templates/Email/Get.php | 147 ++++++++++++ .../Http/Project/Templates/Email/Update.php | 121 ++++++++++ .../Modules/Project/Services/Http.php | 8 + 5 files changed, 377 insertions(+), 219 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..b972103224 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -20,7 +20,6 @@ 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; @@ -833,224 +832,6 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Delete custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php new file mode 100644 index 0000000000..9133971c40 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -0,0 +1,101 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Delete project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateType].delete') + ->label('audits.event', 'project.template.delete') + ->label('audits.resource', 'project.template/{response.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'deleteEmailTemplate', + description: <<param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $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]); + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->noContent(); + } +} 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..3c5888f8b8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -0,0 +1,147 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/: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('type', '', 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.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $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' => '', + 'custom' => false, + ]; + } else { + $template['custom'] = true; + } + + $template['type'] = $type; + $template['locale'] = $locale; + + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); + } +} 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..ff739f9fe8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -0,0 +1,121 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Update project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateType].update') + ->label('audits.event', 'project.template.update') + ->label('audits.resource', 'project.template/{response.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'updateEmailTemplate', + description: <<param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->param('subject', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') + ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') + ->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('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + string $subject, + string $message, + string $senderName, + string $senderEmail, + string $replyTo, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $template = [ + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'subject' => $subject, + 'replyTo' => $replyTo, + 'message' => $message + ]; + + $templates = $project->getAttribute('templates', []); + $templates['email.' . $type . '-' . $locale] = $template; + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->dynamic(new Document([ + 'type' => $type, + 'locale' => $locale, + 'senderName' => $template['senderName'], + 'senderEmail' => $template['senderEmail'], + 'subject' => $template['subject'], + 'replyTo' => $template['replyTo'], + 'message' => $template['message'], + 'custom' => true, + ]), 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..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,6 +24,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; 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\Templates\Email\Delete as DeleteTemplate; +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 +48,11 @@ class Http extends Service $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); + // Templates + $this->addAction(GetTemplate::getName(), new GetTemplate()); + $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); + $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); From 489b2c4e211d442ae3ee41f8ba47f06537a10b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:45:04 +0200 Subject: [PATCH 020/123] Add new scopes --- app/config/roles.php | 2 ++ app/config/scopes/project.php | 8 ++++++++ src/Appwrite/Platform/Workers/Migrations.php | 2 ++ tests/e2e/Scopes/ProjectCustom.php | 2 ++ 4 files changed, 14 insertions(+) diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..0903e79b13 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.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..861e292407 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,12 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "templates.read" => [ + "description" => + "Access to read project\'s templates", + ], + "templates.write" => [ + "description" => + "Access to create, update, and delete project\'s templates", + ], ]; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..e69541820b 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -376,6 +376,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ] ]); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..7d9f5eb126 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ], ]); From b01ec03723f828e329b1e9cca8b80fb607ab115c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:01:27 +0200 Subject: [PATCH 021/123] Fix analyze bug --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 1 + 1 file changed, 1 insertion(+) 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 index 3c5888f8b8..1a44bf24a5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -6,6 +6,7 @@ use Appwrite\Event\Event as QueueEvent; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Database\Database; From e388d2f6a3b950758674f13a1adc9132ca77f898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:22:55 +0200 Subject: [PATCH 022/123] List tempaltes endpoint --- app/init/models.php | 1 + .../Http/Project/Templates/Email/Get.php | 3 +- .../Http/Project/Templates/Email/XList.php | 113 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/TemplateEmail.php | 6 + 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php diff --git a/app/init/models.php b/app/init/models.php index f654c10121..924df52bdd 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,6 +210,7 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); +Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); 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 index 1a44bf24a5..1855f13d17 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -30,8 +30,7 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') + ->setHttpPath('/v1/project/templates/email/:type') ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get project email template') ->groups(['api', 'project']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php new file mode 100644 index 0000000000..5cd5184177 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -0,0 +1,113 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->desc('List project email templates') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'listEmailTemplates', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('localeCodes') + ->callback($this->action(...)); + } + + /** + * @param array $queries + * @param array $localeCodes + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + array $localeCodes, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; + $offset = $grouped['offset'] ?? 0; + + $types = Config::getParam('locale-templates')['email'] ?? []; + $projectTemplates = $project->getAttribute('templates', []); + + $templates = []; + foreach ($types as $type) { + foreach ($localeCodes as $locale) { + $key = 'email.' . $type . '-' . $locale; + $stored = $projectTemplates[$key] ?? null; + + $templates[] = new Document([ + 'type' => $type, + 'locale' => $locale, + 'message' => $stored['message'] ?? '', + 'subject' => $stored['subject'] ?? '', + 'senderName' => $stored['senderName'] ?? '', + 'senderEmail' => $stored['senderEmail'] ?? '', + 'replyTo' => $stored['replyTo'] ?? '', + 'custom' => !\is_null($stored), + ]); + } + } + + $total = $includeTotal ? \count($templates) : 0; + $templates = \array_slice($templates, $offset, $limit); + + $response->dynamic(new Document([ + 'templates' => $templates, + 'total' => $total, + ]), Response::MODEL_EMAIL_TEMPLATE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f6c3a2efc2..b91541928c 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,6 +27,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; 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\Templates\Email\XList as ListTemplates; 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; @@ -49,6 +50,7 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates + $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..4aecc62fd8 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,6 +266,7 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; + public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..95cd57a584 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -34,6 +34,12 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'Please verify your email address', ]) + ->addRule('custom', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the template has been customized for the project. Non-custom templates render from defaults.', + 'default' => false, + 'example' => false, + ]) ; } From dc704fdb5131d7262ee61253419249e31a572f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:27:19 +0200 Subject: [PATCH 023/123] Improved xlist endpoint --- .../Http/Project/Templates/Email/XList.php | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 5cd5184177..3a04cf4490 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -8,12 +8,15 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Config\Config; +use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; +use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -22,6 +25,17 @@ class XList extends Action { use HTTP; + private const ALLOWED_ATTRIBUTES = [ + 'type' => Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + public static function getName() { return 'listProjectEmailTemplates'; @@ -29,10 +43,25 @@ class XList extends Action public function __construct() { + $attributes = []; + foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + $queriesValidator = new Queries([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') ->desc('List project email templates') ->groups(['api', 'project']) ->label('scope', 'templates.read') @@ -51,7 +80,7 @@ class XList extends Action ) ] )) - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -80,6 +109,13 @@ class XList extends Action $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; $offset = $grouped['offset'] ?? 0; + /** @var array $filters */ + $filters = $grouped['filters'] ?? []; + /** @var array $orderAttributes */ + $orderAttributes = $grouped['orderAttributes'] ?? []; + /** @var array $orderTypes */ + $orderTypes = $grouped['orderTypes'] ?? []; + $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -102,6 +138,9 @@ class XList extends Action } } + $templates = $this->applyFilters($templates, $filters); + $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $total = $includeTotal ? \count($templates) : 0; $templates = \array_slice($templates, $offset, $limit); @@ -110,4 +149,80 @@ class XList extends Action 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } + + /** + * @param array $templates + * @param array $filters + * @return array + */ + private function applyFilters(array $templates, array $filters): array + { + if (empty($filters)) { + return $templates; + } + + return \array_values(\array_filter($templates, function (Document $template) use ($filters) { + foreach ($filters as $filter) { + if (!$this->matches($template, $filter)) { + return false; + } + } + return true; + })); + } + + private function matches(Document $template, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $template->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), + Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), + Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, + Query::TYPE_IS_NULL => $actual === null || $actual === '', + Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', + default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), + }; + } + + /** + * @param array $templates + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return $templates; + } + + \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $valueA = $a->getAttribute($attribute); + $valueB = $b->getAttribute($attribute); + + $cmp = $valueA <=> $valueB; + if ($cmp === 0) { + continue; + } + + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + return 0; + }); + + return $templates; + } } From bb38bf42487b0c59cfc7b09f3a72509d94f57709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:42:46 +0200 Subject: [PATCH 024/123] Improve code quality --- .../Http/Project/Templates/Email/XList.php | 128 +------------- .../Utopia/Database/InMemoryQuery.php | 159 ++++++++++++++++++ .../Validator/Queries/BaseInMemory.php | 41 +++++ .../Validator/Queries/ProjectTemplates.php | 24 +++ 4 files changed, 230 insertions(+), 122 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 3a04cf4490..9fdd576af1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,17 +6,13 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\InMemoryQuery; +use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries; -use Utopia\Database\Validator\Query\Filter; -use Utopia\Database\Validator\Query\Limit; -use Utopia\Database\Validator\Query\Offset; -use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -25,17 +21,6 @@ class XList extends Action { use HTTP; - private const ALLOWED_ATTRIBUTES = [ - 'type' => Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - public static function getName() { return 'listProjectEmailTemplates'; @@ -43,22 +28,6 @@ class XList extends Action public function __construct() { - $attributes = []; - foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - $queriesValidator = new Queries([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') @@ -80,7 +49,7 @@ class XList extends Action ) ] )) - ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) + ->param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -106,15 +75,6 @@ class XList extends Action } $grouped = Query::groupByType($queries); - $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; - $offset = $grouped['offset'] ?? 0; - - /** @var array $filters */ - $filters = $grouped['filters'] ?? []; - /** @var array $orderAttributes */ - $orderAttributes = $grouped['orderAttributes'] ?? []; - /** @var array $orderTypes */ - $orderTypes = $grouped['orderTypes'] ?? []; $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -138,91 +98,15 @@ class XList extends Action } } - $templates = $this->applyFilters($templates, $filters); - $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $templates = InMemoryQuery::filter($templates, $grouped['filters']); + $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); $total = $includeTotal ? \count($templates) : 0; - $templates = \array_slice($templates, $offset, $limit); + $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); $response->dynamic(new Document([ 'templates' => $templates, 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } - - /** - * @param array $templates - * @param array $filters - * @return array - */ - private function applyFilters(array $templates, array $filters): array - { - if (empty($filters)) { - return $templates; - } - - return \array_values(\array_filter($templates, function (Document $template) use ($filters) { - foreach ($filters as $filter) { - if (!$this->matches($template, $filter)) { - return false; - } - } - return true; - })); - } - - private function matches(Document $template, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $template->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), - Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), - Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, - Query::TYPE_IS_NULL => $actual === null || $actual === '', - Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', - default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), - }; - } - - /** - * @param array $templates - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return $templates; - } - - \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $valueA = $a->getAttribute($attribute); - $valueB = $b->getAttribute($attribute); - - $cmp = $valueA <=> $valueB; - if ($cmp === 0) { - continue; - } - - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - return 0; - }); - - return $templates; - } } diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php new file mode 100644 index 0000000000..c9f930369a --- /dev/null +++ b/src/Appwrite/Utopia/Database/InMemoryQuery.php @@ -0,0 +1,159 @@ + $documents + * @param array $filters + * @return array + */ + public static function filter(array $documents, array $filters): array + { + if (empty($filters)) { + return \array_values($documents); + } + + return \array_values(\array_filter($documents, function (Document $document) use ($filters) { + foreach ($filters as $filter) { + if (!self::matches($document, $filter)) { + return false; + } + } + return true; + })); + } + + /** + * Evaluate a single filter query against a document. + */ + public static function matches(Document $document, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $document->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, + Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, + Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, + Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, + Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, + Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => self::containsValue($actual, $values), + Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), + Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), + Query::TYPE_IS_NULL => $actual === null, + Query::TYPE_IS_NOT_NULL => $actual !== null, + default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), + }; + } + + /** + * Sort documents by one or more attributes. + * + * @param array $documents + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + public static function order(array $documents, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return \array_values($documents); + } + + $documents = \array_values($documents); + + \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); + if ($cmp !== 0) { + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + } + return 0; + }); + + return $documents; + } + + /** + * Apply limit and offset. + * + * @param array $documents + * @return array + */ + public static function paginate(array $documents, ?int $limit, ?int $offset): array + { + return \array_slice(\array_values($documents), $offset ?? 0, $limit); + } + + /** + * Compare two scalars in a way that handles null consistently (null sorts before any value). + */ + private static function compareScalar(mixed $a, mixed $b): int + { + if ($a === null && $b === null) { + return 0; + } + if ($a === null) { + return -1; + } + if ($b === null) { + return 1; + } + return $a <=> $b; + } + + /** + * Check if an attribute contains any of the given values. Handles both array attributes + * (checks membership) and string attributes (checks substring). + * + * @param array $values + */ + private static function containsValue(mixed $actual, array $values): bool + { + if (\is_array($actual)) { + foreach ($values as $value) { + if (\in_array($value, $actual, false)) { + return true; + } + } + return false; + } + + if (\is_string($actual)) { + foreach ($values as $value) { + if (\str_contains($actual, (string) $value)) { + return true; + } + } + return false; + } + + return false; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php new file mode 100644 index 0000000000..c2df415abd --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php @@ -0,0 +1,41 @@ + $allowedAttributes Map of attribute key to Database::VAR_* type + */ + public function __construct(array $allowedAttributes) + { + $attributes = []; + foreach ($allowedAttributes as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + parent::__construct([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php new file mode 100644 index 0000000000..ea2b7c4cad --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php @@ -0,0 +1,24 @@ + Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + + public function __construct() + { + parent::__construct(self::ALLOWED_ATTRIBUTES); + } +} From 64d182ac6a109d529b80fecfaef0b1d16ea5a7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:49:20 +0200 Subject: [PATCH 025/123] Add tests for templates --- tests/e2e/Services/Project/TemplatesBase.php | 575 ++++++++++++++++++ .../Project/TemplatesConsoleClientTest.php | 14 + .../Project/TemplatesCustomServerTest.php | 14 + 3 files changed, 603 insertions(+) create mode 100644 tests/e2e/Services/Project/TemplatesBase.php create mode 100644 tests/e2e/Services/Project/TemplatesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/TemplatesCustomServerTest.php diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php new file mode 100644 index 0000000000..34fe6ee491 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -0,0 +1,575 @@ +getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $template['headers']['status-code']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + $this->assertFalse($template['body']['custom']); + $this->assertNotEmpty($template['body']['subject']); + $this->assertNotEmpty($template['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + $template = $this->getEmailTemplate('verification'); + + $this->assertSame(200, $template['headers']['status-code']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + $this->assertFalse($template['body']['custom']); + } + + public function testGetEmailTemplateCustom(): void + { + $update = $this->updateEmailTemplate('magicSession', 'en', 'Magic Subject', 'Magic Body'); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('magicSession', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('magicSession', $get['body']['type']); + $this->assertSame('en', $get['body']['locale']); + $this->assertTrue($get['body']['custom']); + $this->assertSame('Magic Subject', $get['body']['subject']); + $this->assertSame('Magic Body', $get['body']['message']); + + // Cleanup + $this->deleteEmailTemplate('magicSession', 'en'); + } + + public function testGetEmailTemplateInvalidType(): void + { + $template = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $template['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $template = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $template['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $template = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $template['headers']['status-code']); + } + + // ========================================================================= + // List email templates tests + // ========================================================================= + + public function testListEmailTemplates(): void + { + $list = $this->listEmailTemplates(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['templates']); + $this->assertGreaterThan(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + + foreach ($list['body']['templates'] as $template) { + $this->assertArrayHasKey('type', $template); + $this->assertArrayHasKey('locale', $template); + $this->assertArrayHasKey('custom', $template); + $this->assertArrayHasKey('subject', $template); + $this->assertArrayHasKey('message', $template); + } + } + + public function testListEmailTemplatesWithLimit(): void + { + $list = $this->listEmailTemplates([ + Query::limit(5)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(5, $list['body']['templates']); + $this->assertGreaterThanOrEqual(5, $list['body']['total']); + } + + public function testListEmailTemplatesWithOffset(): void + { + $first = $this->listEmailTemplates([ + Query::limit(2)->toString(), + ], true); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->listEmailTemplates([ + Query::limit(2)->toString(), + Query::offset(2)->toString(), + ], true); + $this->assertSame(200, $second['headers']['status-code']); + + $firstIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $first['body']['templates'] + ); + $secondIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $second['body']['templates'] + ); + + $this->assertEmpty(\array_intersect($firstIds, $secondIds)); + } + + public function testListEmailTemplatesWithoutTotal(): void + { + $list = $this->listEmailTemplates(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + } + + public function testListEmailTemplatesFilterByType(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('verification', $template['type']); + } + } + + public function testListEmailTemplatesFilterByLocale(): void + { + $list = $this->listEmailTemplates([ + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('en', $template['locale']); + } + } + + public function testListEmailTemplatesFilterByCustom(): void + { + $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); + $this->assertSame(200, $update['headers']['status-code']); + + $list = $this->listEmailTemplates([ + Query::equal('custom', [true])->toString(), + Query::limit(100)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + $found = false; + foreach ($list['body']['templates'] as $template) { + $this->assertTrue($template['custom']); + if ($template['type'] === 'recovery' && $template['locale'] === 'en') { + $found = true; + } + } + $this->assertTrue($found, 'Customized template should appear in custom=true filter'); + + // Cleanup + $this->deleteEmailTemplate('recovery', 'en'); + } + + public function testListEmailTemplatesCombinedFilters(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(1, $list['body']['total']); + $this->assertCount(1, $list['body']['templates']); + $this->assertSame('verification', $list['body']['templates'][0]['type']); + $this->assertSame('en', $list['body']['templates'][0]['locale']); + } + + public function testListEmailTemplatesOrderByType(): void + { + $asc = $this->listEmailTemplates([ + Query::orderAsc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $asc['headers']['status-code']); + + $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); + $sorted = $ascTypes; + \sort($sorted); + $this->assertSame($sorted, $ascTypes); + + $desc = $this->listEmailTemplates([ + Query::orderDesc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $desc['headers']['status-code']); + + $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); + $sorted = $descTypes; + \rsort($sorted); + $this->assertSame($sorted, $descTypes); + } + + public function testListEmailTemplatesInvalidQuery(): void + { + $list = $this->listEmailTemplates([ + Query::equal('notAnAttribute', ['foo'])->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + public function testListEmailTemplatesWithoutAuthentication(): void + { + $list = $this->listEmailTemplates(null, true, false); + + $this->assertSame(401, $list['headers']['status-code']); + } + + // ========================================================================= + // Update email template tests + // ========================================================================= + + public function testUpdateEmailTemplate(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Please verify your email', + 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('verification', $update['body']['type']); + $this->assertSame('en', $update['body']['locale']); + $this->assertSame('Please verify your email', $update['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); + $this->assertTrue($update['body']['custom']); + + // Verify persisted via GET + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Please verify your email', $get['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $get['body']['message']); + $this->assertTrue($get['body']['custom']); + + // Cleanup + $this->deleteEmailTemplate('verification', 'en'); + } + + public function testUpdateEmailTemplateWithOptionalFields(): void + { + $update = $this->updateEmailTemplate( + 'invitation', + 'en', + 'Team invitation', + 'You have been invited', + 'Appwrite Team', + 'team@appwrite.io', + 'reply@appwrite.io', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('Team invitation', $update['body']['subject']); + $this->assertSame('You have been invited', $update['body']['message']); + $this->assertSame('Appwrite Team', $update['body']['senderName']); + $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); + $this->assertSame('reply@appwrite.io', $update['body']['replyTo']); + + // Cleanup + $this->deleteEmailTemplate('invitation', 'en'); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $update = $this->updateEmailTemplate( + 'sessionAlert', + null, + 'Session alert', + 'Someone signed in', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('sessionAlert', $update['body']['type']); + $this->assertSame('en', $update['body']['locale']); + + // Cleanup + $this->deleteEmailTemplate('sessionAlert', 'en'); + } + + public function testUpdateEmailTemplateOverwrite(): void + { + $this->updateEmailTemplate('otpSession', 'en', 'First', 'First body'); + + $second = $this->updateEmailTemplate('otpSession', 'en', 'Second', 'Second body'); + + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second', $get['body']['subject']); + + // Cleanup + $this->deleteEmailTemplate('otpSession', 'en'); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $update = $this->updateEmailTemplate('notATemplate', 'en', 'Subject', 'Message'); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubject(): void + { + $update = $this->updateEmailTemplate('verification', 'en', null, 'Message only'); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingMessage(): void + { + $update = $this->updateEmailTemplate('verification', 'en', 'Subject only', null); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + 'Sender', + 'not-an-email', + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyTo(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + null, + null, + 'not-an-email', + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + null, + null, + null, + false, + ); + + $this->assertSame(401, $update['headers']['status-code']); + } + + // ========================================================================= + // Delete email template tests + // ========================================================================= + + public function testDeleteEmailTemplate(): void + { + $update = $this->updateEmailTemplate('mfaChallenge', 'en', 'MFA', 'Enter code'); + $this->assertSame(200, $update['headers']['status-code']); + + $customBefore = $this->getEmailTemplate('mfaChallenge', 'en'); + $this->assertTrue($customBefore['body']['custom']); + + $delete = $this->deleteEmailTemplate('mfaChallenge', 'en'); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify reset back to default + $after = $this->getEmailTemplate('mfaChallenge', 'en'); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertFalse($after['body']['custom']); + $this->assertNotSame('MFA', $after['body']['subject']); + } + + public function testDeleteEmailTemplateDefault(): void + { + // Attempt to delete a template that was never customized + $delete = $this->deleteEmailTemplate('verification', 'fr'); + + $this->assertSame(401, $delete['headers']['status-code']); + $this->assertSame('project_template_default_deletion', $delete['body']['type']); + } + + public function testDeleteEmailTemplateInvalidType(): void + { + $delete = $this->deleteEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $delete['headers']['status-code']); + } + + public function testDeleteEmailTemplateWithoutAuthentication(): void + { + $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery', 'Reset password'); + $this->assertSame(200, $update['headers']['status-code']); + + $delete = $this->deleteEmailTemplate('recovery', 'en', false); + + $this->assertSame(401, $delete['headers']['status-code']); + + // Verify still customized + $get = $this->getEmailTemplate('recovery', 'en'); + $this->assertTrue($get['body']['custom']); + + // Cleanup + $this->deleteEmailTemplate('recovery', 'en'); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function getEmailTemplate(string $type, ?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/' . $type, $headers, $params); + } + + /** + * @param array|null $queries + */ + protected function listEmailTemplates(?array $queries, ?bool $total, 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 ($queries !== null) { + $params['queries'] = $queries; + } + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); + } + + protected function updateEmailTemplate( + string $type, + ?string $locale, + ?string $subject, + ?string $message, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyTo = null, + bool $authenticated = true, + ): mixed { + $params = [ + 'type' => $type, + ]; + + if ($locale !== null) { + $params['locale'] = $locale; + } + if ($subject !== null) { + $params['subject'] = $subject; + } + if ($message !== null) { + $params['message'] = $message; + } + if ($senderName !== null) { + $params['senderName'] = $senderName; + } + if ($senderEmail !== null) { + $params['senderEmail'] = $senderEmail; + } + if ($replyTo !== null) { + $params['replyTo'] = $replyTo; + } + + $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_PATCH, '/project/templates/email', $headers, $params); + } + + protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed + { + $params = [ + 'type' => $type, + ]; + + if ($locale !== null) { + $params['locale'] = $locale; + } + + $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_DELETE, '/project/templates/email', $headers, $params); + } +} 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 @@ + Date: Fri, 17 Apr 2026 18:15:49 +0200 Subject: [PATCH 026/123] Fix bug --- .../Project/Http/Project/Templates/Email/Get.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 index 1855f13d17..a5a4d8ce8f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -2,16 +2,13 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; -use Appwrite\Event\Event as QueueEvent; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Validator\Authorization; use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -53,9 +50,6 @@ class Get extends Action ->param('type', '', 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.', optional: true, injections: ['localeCodes']) ->inject('response') - ->inject('queueForEvents') - ->inject('dbForPlatform') - ->inject('authorization') ->inject('project') ->inject('locale') ->callback($this->action(...)); @@ -65,9 +59,6 @@ class Get extends Action string $type, string $locale, Response $response, - QueueEvent $queueForEvents, - Database $dbForPlatform, - Authorization $authorization, Document $project, Locale $localeObject, ) { @@ -108,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From be5eeb1abac7902053282dcb407325a15a99351e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 19:50:34 +0200 Subject: [PATCH 027/123] Fix failing tests --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a5a4d8ce8f..9725215613 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -99,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From fcc7a56f4af2354ebaebfa3758a508983e756d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:01:26 +0200 Subject: [PATCH 028/123] Fix list endpoint --- .../Http/Project/Templates/Email/XList.php | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 9fdd576af1..0128a18e25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,6 +6,7 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Template\Template; use Appwrite\Utopia\Database\InMemoryQuery; use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; @@ -13,8 +14,10 @@ use Utopia\Config\Config; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; +use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\System\System; use Utopia\Validator\Boolean; class XList extends Action @@ -85,16 +88,74 @@ class XList extends Action $key = 'email.' . $type . '-' . $locale; $stored = $projectTemplates[$key] ?? null; - $templates[] = new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $stored['message'] ?? '', - 'subject' => $stored['subject'] ?? '', - 'senderName' => $stored['senderName'] ?? '', - 'senderEmail' => $stored['senderEmail'] ?? '', - 'replyTo' => $stored['replyTo'] ?? '', - 'custom' => !\is_null($stored), - ]); + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + if (is_null($stored)) { + /** + * 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(APP_CE_CONFIG_DIR . '/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' => '', + 'custom' => false, + ]; + } else { + $template = $stored; + $template['custom'] = true; + } + + $template['type'] = $type; + $template['locale'] = $locale; + + $templates[] = new Document($template); } } From 0a71efc24494ec15189a426472cd717e79615f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:23:53 +0200 Subject: [PATCH 029/123] improve test coverage --- tests/e2e/Services/Project/TemplatesBase.php | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 34fe6ee491..27601eddbe 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -207,6 +207,32 @@ trait TemplatesBase $this->assertSame('en', $list['body']['templates'][0]['locale']); } + public function testListEmailTemplatesDefaultMatchesGet(): void + { + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertFalse($get['body']['custom']); + + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['templates']); + + $listed = $list['body']['templates'][0]; + + $this->assertSame($get['body']['type'], $listed['type']); + $this->assertSame($get['body']['locale'], $listed['locale']); + $this->assertSame($get['body']['custom'], $listed['custom']); + $this->assertSame($get['body']['subject'], $listed['subject']); + $this->assertSame($get['body']['message'], $listed['message']); + $this->assertSame($get['body']['senderName'], $listed['senderName']); + $this->assertSame($get['body']['senderEmail'], $listed['senderEmail']); + $this->assertSame($get['body']['replyTo'], $listed['replyTo']); + } + public function testListEmailTemplatesOrderByType(): void { $asc = $this->listEmailTemplates([ From 8a1f8c71b24439433537aaa329a3a6b14b0f15cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:15:48 +0200 Subject: [PATCH 030/123] Temporary removal of listEmailTemplates Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Http/Project/Templates/Email/XList.php | 173 ------------- .../Modules/Project/Services/Http.php | 2 - .../Utopia/Database/InMemoryQuery.php | 159 ------------ .../Validator/Queries/BaseInMemory.php | 41 ---- .../Validator/Queries/ProjectTemplates.php | 24 -- tests/e2e/Services/Project/TemplatesBase.php | 228 ------------------ 6 files changed, 627 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php delete mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php deleted file mode 100644 index 0128a18e25..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ /dev/null @@ -1,173 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email') - ->desc('List project email templates') - ->groups(['api', 'project']) - ->label('scope', 'templates.read') - ->label('sdk', new Method( - namespace: 'project', - group: 'templates', - name: 'listEmailTemplates', - description: <<param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('project') - ->inject('response') - ->inject('localeCodes') - ->callback($this->action(...)); - } - - /** - * @param array $queries - * @param array $localeCodes - */ - public function action( - array $queries, - bool $includeTotal, - Document $project, - Response $response, - array $localeCodes, - ) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $grouped = Query::groupByType($queries); - - $types = Config::getParam('locale-templates')['email'] ?? []; - $projectTemplates = $project->getAttribute('templates', []); - - $templates = []; - foreach ($types as $type) { - foreach ($localeCodes as $locale) { - $key = 'email.' . $type . '-' . $locale; - $stored = $projectTemplates[$key] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($stored)) { - /** - * 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(APP_CE_CONFIG_DIR . '/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' => '', - 'custom' => false, - ]; - } else { - $template = $stored; - $template['custom'] = true; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $templates[] = new Document($template); - } - } - - $templates = InMemoryQuery::filter($templates, $grouped['filters']); - $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); - - $total = $includeTotal ? \count($templates) : 0; - $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); - - $response->dynamic(new Document([ - 'templates' => $templates, - 'total' => $total, - ]), Response::MODEL_EMAIL_TEMPLATE_LIST); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b91541928c..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,7 +27,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; 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\Templates\Email\XList as ListTemplates; 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; @@ -50,7 +49,6 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates - $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php deleted file mode 100644 index c9f930369a..0000000000 --- a/src/Appwrite/Utopia/Database/InMemoryQuery.php +++ /dev/null @@ -1,159 +0,0 @@ - $documents - * @param array $filters - * @return array - */ - public static function filter(array $documents, array $filters): array - { - if (empty($filters)) { - return \array_values($documents); - } - - return \array_values(\array_filter($documents, function (Document $document) use ($filters) { - foreach ($filters as $filter) { - if (!self::matches($document, $filter)) { - return false; - } - } - return true; - })); - } - - /** - * Evaluate a single filter query against a document. - */ - public static function matches(Document $document, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $document->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, - Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, - Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, - Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, - Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, - Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => self::containsValue($actual, $values), - Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), - Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), - Query::TYPE_IS_NULL => $actual === null, - Query::TYPE_IS_NOT_NULL => $actual !== null, - default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), - }; - } - - /** - * Sort documents by one or more attributes. - * - * @param array $documents - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - public static function order(array $documents, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return \array_values($documents); - } - - $documents = \array_values($documents); - - \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); - if ($cmp !== 0) { - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - } - return 0; - }); - - return $documents; - } - - /** - * Apply limit and offset. - * - * @param array $documents - * @return array - */ - public static function paginate(array $documents, ?int $limit, ?int $offset): array - { - return \array_slice(\array_values($documents), $offset ?? 0, $limit); - } - - /** - * Compare two scalars in a way that handles null consistently (null sorts before any value). - */ - private static function compareScalar(mixed $a, mixed $b): int - { - if ($a === null && $b === null) { - return 0; - } - if ($a === null) { - return -1; - } - if ($b === null) { - return 1; - } - return $a <=> $b; - } - - /** - * Check if an attribute contains any of the given values. Handles both array attributes - * (checks membership) and string attributes (checks substring). - * - * @param array $values - */ - private static function containsValue(mixed $actual, array $values): bool - { - if (\is_array($actual)) { - foreach ($values as $value) { - if (\in_array($value, $actual, false)) { - return true; - } - } - return false; - } - - if (\is_string($actual)) { - foreach ($values as $value) { - if (\str_contains($actual, (string) $value)) { - return true; - } - } - return false; - } - - return false; - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php deleted file mode 100644 index c2df415abd..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php +++ /dev/null @@ -1,41 +0,0 @@ - $allowedAttributes Map of attribute key to Database::VAR_* type - */ - public function __construct(array $allowedAttributes) - { - $attributes = []; - foreach ($allowedAttributes as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - parent::__construct([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php deleted file mode 100644 index ea2b7c4cad..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php +++ /dev/null @@ -1,24 +0,0 @@ - Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - - public function __construct() - { - parent::__construct(self::ALLOWED_ATTRIBUTES); - } -} diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 27601eddbe..45e09779b9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; -use Utopia\Database\Query; trait TemplatesBase { @@ -72,208 +71,6 @@ trait TemplatesBase $this->assertSame(401, $template['headers']['status-code']); } - // ========================================================================= - // List email templates tests - // ========================================================================= - - public function testListEmailTemplates(): void - { - $list = $this->listEmailTemplates(null, true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertIsArray($list['body']['templates']); - $this->assertGreaterThan(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - - foreach ($list['body']['templates'] as $template) { - $this->assertArrayHasKey('type', $template); - $this->assertArrayHasKey('locale', $template); - $this->assertArrayHasKey('custom', $template); - $this->assertArrayHasKey('subject', $template); - $this->assertArrayHasKey('message', $template); - } - } - - public function testListEmailTemplatesWithLimit(): void - { - $list = $this->listEmailTemplates([ - Query::limit(5)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(5, $list['body']['templates']); - $this->assertGreaterThanOrEqual(5, $list['body']['total']); - } - - public function testListEmailTemplatesWithOffset(): void - { - $first = $this->listEmailTemplates([ - Query::limit(2)->toString(), - ], true); - $this->assertSame(200, $first['headers']['status-code']); - - $second = $this->listEmailTemplates([ - Query::limit(2)->toString(), - Query::offset(2)->toString(), - ], true); - $this->assertSame(200, $second['headers']['status-code']); - - $firstIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $first['body']['templates'] - ); - $secondIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $second['body']['templates'] - ); - - $this->assertEmpty(\array_intersect($firstIds, $secondIds)); - } - - public function testListEmailTemplatesWithoutTotal(): void - { - $list = $this->listEmailTemplates(null, false); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - } - - public function testListEmailTemplatesFilterByType(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('verification', $template['type']); - } - } - - public function testListEmailTemplatesFilterByLocale(): void - { - $list = $this->listEmailTemplates([ - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('en', $template['locale']); - } - } - - public function testListEmailTemplatesFilterByCustom(): void - { - $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); - $this->assertSame(200, $update['headers']['status-code']); - - $list = $this->listEmailTemplates([ - Query::equal('custom', [true])->toString(), - Query::limit(100)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $list['body']['total']); - - $found = false; - foreach ($list['body']['templates'] as $template) { - $this->assertTrue($template['custom']); - if ($template['type'] === 'recovery' && $template['locale'] === 'en') { - $found = true; - } - } - $this->assertTrue($found, 'Customized template should appear in custom=true filter'); - - // Cleanup - $this->deleteEmailTemplate('recovery', 'en'); - } - - public function testListEmailTemplatesCombinedFilters(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(1, $list['body']['total']); - $this->assertCount(1, $list['body']['templates']); - $this->assertSame('verification', $list['body']['templates'][0]['type']); - $this->assertSame('en', $list['body']['templates'][0]['locale']); - } - - public function testListEmailTemplatesDefaultMatchesGet(): void - { - $get = $this->getEmailTemplate('verification', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertFalse($get['body']['custom']); - - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(1, $list['body']['templates']); - - $listed = $list['body']['templates'][0]; - - $this->assertSame($get['body']['type'], $listed['type']); - $this->assertSame($get['body']['locale'], $listed['locale']); - $this->assertSame($get['body']['custom'], $listed['custom']); - $this->assertSame($get['body']['subject'], $listed['subject']); - $this->assertSame($get['body']['message'], $listed['message']); - $this->assertSame($get['body']['senderName'], $listed['senderName']); - $this->assertSame($get['body']['senderEmail'], $listed['senderEmail']); - $this->assertSame($get['body']['replyTo'], $listed['replyTo']); - } - - public function testListEmailTemplatesOrderByType(): void - { - $asc = $this->listEmailTemplates([ - Query::orderAsc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $asc['headers']['status-code']); - - $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); - $sorted = $ascTypes; - \sort($sorted); - $this->assertSame($sorted, $ascTypes); - - $desc = $this->listEmailTemplates([ - Query::orderDesc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $desc['headers']['status-code']); - - $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); - $sorted = $descTypes; - \rsort($sorted); - $this->assertSame($sorted, $descTypes); - } - - public function testListEmailTemplatesInvalidQuery(): void - { - $list = $this->listEmailTemplates([ - Query::equal('notAnAttribute', ['foo'])->toString(), - ], true); - - $this->assertSame(400, $list['headers']['status-code']); - } - - public function testListEmailTemplatesWithoutAuthentication(): void - { - $list = $this->listEmailTemplates(null, true, false); - - $this->assertSame(401, $list['headers']['status-code']); - } - // ========================================================================= // Update email template tests // ========================================================================= @@ -507,31 +304,6 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $type, $headers, $params); } - /** - * @param array|null $queries - */ - protected function listEmailTemplates(?array $queries, ?bool $total, 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 ($queries !== null) { - $params['queries'] = $queries; - } - if ($total !== null) { - $params['total'] = $total; - } - - return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); - } - protected function updateEmailTemplate( string $type, ?string $locale, From 2a95cfd5a3a6d973970e03ae03216eb579b313a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:35:57 +0200 Subject: [PATCH 031/123] Final template API rework --- .github/workflows/ci.yml | 3 +- CHANGES.md | 8 ++ README-CN.md | 6 +- README.md | 6 +- app/controllers/general.php | 8 ++ app/init/constants.php | 4 +- src/Appwrite/Migration/Migration.php | 1 + .../Http/Project/Templates/Email/Delete.php | 18 +-- .../Http/Project/Templates/Email/Get.php | 28 ++--- .../Http/Project/Templates/Email/Update.php | 18 +-- src/Appwrite/Utopia/Request/Filters/V23.php | 31 +++++ src/Appwrite/Utopia/Response/Filters/V23.php | 28 +++++ .../Utopia/Response/Model/Template.php | 32 ----- .../Utopia/Response/Model/TemplateEmail.php | 22 +++- tests/e2e/Services/Project/TemplatesBase.php | 113 ++++++++++++++++-- 15 files changed, 243 insertions(+), 83 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V23.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V23.php delete mode 100644 src/Appwrite/Utopia/Response/Model/Template.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8256ddc7a..48c8ea901b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -701,9 +701,8 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml + # TODO: Minify docker-compose; remove development tooling rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml curl https://appwrite.io/install/env -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d diff --git a/CHANGES.md b/CHANGES.md index 548c0d72b0..d717de4668 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +# Version 1.9.2 + +### Notable changes + +### Fixes + +### Miscellaneous + # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 2c7402f1ef..7f758bc247 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.2 ``` ### 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.2 ``` #### 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.2 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 31076ffa31..b0926bbeeb 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.2 ``` ### 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.2 ``` #### 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.2 ``` 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/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..596fbd0926 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; @@ -897,6 +899,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', '')); @@ -921,6 +926,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/init/constants.php b/app/init/constants.php index f2127cd666..54a94652fb 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,8 +46,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/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/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 9133971c40..cf02704d47 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -33,13 +33,13 @@ class Delete extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Delete project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].delete') ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{response.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -56,8 +56,8 @@ class Delete extends Action ], contentType: ContentType::NONE )) - ->param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->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('queueForEvents') ->inject('dbForPlatform') @@ -68,7 +68,7 @@ class Delete extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, QueueEvent $queueForEvents, @@ -77,16 +77,16 @@ class Delete extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; if (is_null($template)) { throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); } - unset($templates['email.' . $type . '-' . $locale]); + unset($templates['email.' . $templateId . '-' . $locale]); $updates = new Document([ 'templates' => $templates, @@ -94,7 +94,7 @@ class Delete extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->noContent(); } 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 index 9725215613..115b10f7dd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -27,8 +27,8 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email/:type') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->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') @@ -47,8 +47,8 @@ class Get extends Action ) ] )) - ->param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->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') ->inject('locale') @@ -56,16 +56,16 @@ class Get extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -94,7 +94,7 @@ class Get extends Action ]; // fallback to the base template. - $config = $templateConfigs[$type] ?? [ + $config = $templateConfigs[$templateId] ?? [ 'file' => 'email-inner-base.tpl', 'placeholders' => ['buttonText', 'body', 'footer'] ]; @@ -107,21 +107,21 @@ class Get extends Action // 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->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$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")); + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); // `useContent: false` will strip new lines! $message = $message->render(useContent: true); $template = [ 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', 'custom' => false, @@ -130,7 +130,7 @@ class Get extends Action $template['custom'] = true; } - $template['type'] = $type; + $template['templateId'] = $templateId; $template['locale'] = $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); 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 index ff739f9fe8..f17be381f6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -33,13 +33,13 @@ class Update extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Update project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].update') ->label('audits.event', 'project.template.update') - ->label('audits.resource', 'project.template/{response.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -55,8 +55,8 @@ class Update extends Action ) ] )) - ->param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->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', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) @@ -72,7 +72,7 @@ class Update extends Action } public function action( - string $type, + string $templateId, string $locale, string $subject, string $message, @@ -86,7 +86,7 @@ class Update extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $template = [ 'senderName' => $senderName, @@ -97,7 +97,7 @@ class Update extends Action ]; $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = $template; + $templates['email.' . $templateId . '-' . $locale] = $template; $updates = new Document([ 'templates' => $templates, @@ -105,10 +105,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->dynamic(new Document([ - 'type' => $type, + 'templateId' => $templateId, 'locale' => $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..adb5d69aea --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,31 @@ +parseEmailTemplate($content); + break; + } + 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..54fcf8459f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,28 @@ + $this->parseEmailTemplate($content), + default => $content, + }; + } + + private function parseEmailTemplate(array $content): array + { + if (isset($content['templateId'])) { + $content['type'] = $content['templateId']; + unset($content['templateId']); + } + + return $content; + } +} 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 95cd57a584..9b77617b8c 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', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 45e09779b9..b61f85aeef 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -15,7 +15,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification', 'en'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); $this->assertNotEmpty($template['body']['subject']); @@ -27,7 +27,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); } @@ -40,7 +40,7 @@ trait TemplatesBase $get = $this->getEmailTemplate('magicSession', 'en'); $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('magicSession', $get['body']['type']); + $this->assertSame('magicSession', $get['body']['templateId']); $this->assertSame('en', $get['body']['locale']); $this->assertTrue($get['body']['custom']); $this->assertSame('Magic Subject', $get['body']['subject']); @@ -85,7 +85,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('verification', $update['body']['type']); + $this->assertSame('verification', $update['body']['templateId']); $this->assertSame('en', $update['body']['locale']); $this->assertSame('Please verify your email', $update['body']['subject']); $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); @@ -135,7 +135,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('sessionAlert', $update['body']['type']); + $this->assertSame('sessionAlert', $update['body']['templateId']); $this->assertSame('en', $update['body']['locale']); // Cleanup @@ -281,6 +281,105 @@ trait TemplatesBase $this->deleteEmailTemplate('recovery', 'en'); } + // ========================================================================= + // Legacy response format tests (request + response filters) + // ========================================================================= + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $template = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + $headers, + ); + + $this->assertSame(200, $template['headers']['status-code']); + // Response filter should rename templateId -> type for < 1.9.2 clients. + $this->assertArrayHasKey('type', $template['body']); + $this->assertArrayNotHasKey('templateId', $template['body']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Request filter should accept legacy `type` and map it to `templateId`. + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy Subject', + 'message' => 'Legacy Body', + ], + ); + + $this->assertSame(200, $update['headers']['status-code']); + // Response filter should rename templateId -> type for < 1.9.2 clients. + $this->assertArrayHasKey('type', $update['body']); + $this->assertArrayNotHasKey('templateId', $update['body']); + $this->assertSame('magicSession', $update['body']['type']); + $this->assertSame('Legacy Subject', $update['body']['subject']); + $this->assertSame('Legacy Body', $update['body']['message']); + $this->assertTrue($update['body']['custom']); + + // Verify persisted, then cleanup via legacy DELETE with `type`. + $get = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['custom']); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'magicSession', + 'locale' => 'en', + ], + ); + $this->assertSame(204, $delete['headers']['status-code']); + + $after = $this->getEmailTemplate('magicSession', 'en'); + $this->assertFalse($after['body']['custom']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + // ========================================================================= // Helpers // ========================================================================= @@ -315,7 +414,7 @@ trait TemplatesBase bool $authenticated = true, ): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { @@ -352,7 +451,7 @@ trait TemplatesBase protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { From afab349a7792b1d970a5fcdbb8cc57a2f9101be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:43:57 +0200 Subject: [PATCH 032/123] self review fixes --- CHANGES.md | 8 --- README-CN.md | 6 +-- README.md | 6 +-- tests/e2e/Services/Project/TemplatesBase.php | 54 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d717de4668..548c0d72b0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,3 @@ -# Version 1.9.2 - -### Notable changes - -### Fixes - -### Miscellaneous - # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 7f758bc247..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.2 + 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.2 + 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.2 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index b0926bbeeb..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.2 + 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.2 + 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.2 + 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/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b61f85aeef..4f78992079 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -357,6 +357,60 @@ trait TemplatesBase $this->assertFalse($after['body']['custom']); } + public function testDeleteEmailTemplateLegacyResponseFormat(): void + { + // Seed a custom template using the current API. + $update = $this->updateEmailTemplate('otpSession', 'en', 'Legacy OTP', 'Legacy OTP body'); + $this->assertSame(200, $update['headers']['status-code']); + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Request filter should accept legacy `type` and map it to `templateId`. + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'otpSession', + 'locale' => 'en', + ], + ); + + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify reset back to default. + $after = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertFalse($after['body']['custom']); + $this->assertNotSame('Legacy OTP', $after['body']['subject']); + } + + public function testDeleteEmailTemplateLegacyInvalidType(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'notATemplate', + 'locale' => 'en', + ], + ); + + $this->assertSame(400, $delete['headers']['status-code']); + } + public function testUpdateEmailTemplateLegacyInvalidType(): void { $headers = \array_merge([ From 447375dcbfec003feac0bea27ae7ba4e66cf01a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:53:11 +0200 Subject: [PATCH 033/123] Fix tests --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..4400c337ac 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1121,6 +1121,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']); @@ -1133,6 +1134,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'subject' => 'Please verify your email', 'message' => 'Please verify your email {{url}}', @@ -1152,6 +1154,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1219,6 +1222,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', // Intentionally no locale @@ -1237,6 +1241,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', From 69d53cb2d4553ad074f2feb7c04bfb38a351cc18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 11:03:36 +0200 Subject: [PATCH 034/123] Remove unused email template list response model Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/models.php | 1 - src/Appwrite/Utopia/Response.php | 1 - 2 files changed, 2 deletions(-) diff --git a/app/init/models.php b/app/init/models.php index 924df52bdd..f654c10121 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,7 +210,6 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); -Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 4aecc62fd8..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,7 +266,6 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; - public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; From d2230f8fe79d48a1302da70f17a0fe27b51af3b6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 19 Apr 2026 17:31:20 +0530 Subject: [PATCH 035/123] chore: bump PHPStan to level 4 and fix all new errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raises `phpstan.neon` level from 3 to 4 and fixes the 549 new errors that level 4 surfaces across 157 files. Fixes are root-cause — no `@phpstan-ignore`, no `@var` casts, no baseline entries, no widened types. A handful of latent bugs were fixed along the way: - `app/controllers/general.php`: path-traversal guard was negating `\substr(...)` before the strict comparison (`!\substr(...) === $base` was always `false === $base`). Rewritten as `\substr(...) !== $base`. - `src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php` and `.../TablesDB/Logs/XList.php`: were importing the raw Matomo `DeviceDetector` (whose `getDevice()` returns `?int`) but treating the result as an array with `deviceName/deviceBrand/deviceModel` keys. Swapped to `Appwrite\Detector\Detector`, matching the wrapper already used a few lines below for `$os`/`$client`. - `src/Appwrite/Platform/Modules/Functions/Workers/Builds.php`: a match key was checking `$resourceKey === 'functions'` when `$resourceKey` is `'functionId'|'siteId'` — always false. Switched to the intended `$resource->getCollection() === 'functions'` check. - `src/Appwrite/OpenSSL/OpenSSL.php`: `encrypt()` return type tightened to `string|false` to match `openssl_encrypt`; this lets callers' `=== false` error handling remain meaningful. - `app/controllers/api/messaging.php`: removed a dead `array_key_exists('from', [])` branch in the Msg91 provider (empty array literal; branch was unreachable). Large cleanup categories across the 549 fixes: - Removed redundant `?? default` on array offsets and expressions that PHPStan now knows are non-nullable. - Removed unreachable statements (mostly `return;` after `throw` or `markTestSkipped()`). - Removed redundant `is_array`/`is_string`/`is_bool`/`instanceof` checks on already-narrowed types. - Added `default =>` arms (or throwing arms) to non-exhaustive matches on `string`/`mixed` input. - Removed dead `$document === false` branches where method return types were tightened to non-nullable `Document`. - Removed unused properties (`$version` on Etsy/Zoom OAuth2, `$paths` on Installer State, `$source` on MigrationsWorker, `$account2` on two GraphQL auth tests), unused traits (`ApiVectorsDB`, `DatabaseFixture`), and an unused `cleanupStaleExecutions` task method. - Replaced `assertTrue(true)` and redundant `assertIsArray`/`assertIsString`/ `assertNotNull` assertions with `addToAssertionCount(1)` or `assertNotEmpty` where the runtime type was already known. --- app/controllers/api/account.php | 55 ++-- app/controllers/api/messaging.php | 15 +- app/controllers/api/migrations.php | 3 +- app/controllers/api/project.php | 3 +- app/controllers/api/projects.php | 1 - app/controllers/api/users.php | 9 +- app/controllers/general.php | 77 +++--- app/controllers/mock.php | 2 +- app/controllers/shared/api.php | 18 +- app/init/realtime/connection.php | 4 +- app/init/registers.php | 13 +- app/init/resources.php | 2 +- app/init/resources/request.php | 12 +- app/init/worker/message.php | 2 +- app/worker.php | 2 +- phpstan.neon | 2 +- src/Appwrite/Auth/OAuth2/Etsy.php | 5 - src/Appwrite/Auth/OAuth2/Podio.php | 2 +- src/Appwrite/Auth/OAuth2/Zoom.php | 5 - src/Appwrite/Auth/Validator/PersonalData.php | 2 +- src/Appwrite/Docker/Compose/Service.php | 2 +- src/Appwrite/Docker/Env.php | 2 +- src/Appwrite/Event/Event.php | 2 +- src/Appwrite/Event/Validator/Event.php | 5 +- src/Appwrite/Event/Webhook.php | 2 +- src/Appwrite/GraphQL/Types/Mapper.php | 26 +- src/Appwrite/Messaging/Adapter/Realtime.php | 9 +- src/Appwrite/OpenSSL/OpenSSL.php | 2 +- .../Installer/Http/Installer/Install.php | 14 +- .../Installer/Http/Installer/Status.php | 4 +- .../Platform/Installer/Runtime/Config.php | 2 +- .../Platform/Installer/Runtime/State.php | 6 +- src/Appwrite/Platform/Installer/Server.php | 2 +- .../Installer/Validator/AppDomain.php | 2 +- .../Avatars/Http/Cards/Cloud/Front/Get.php | 4 +- .../Avatars/Http/Cards/Cloud/OG/Get.php | 4 +- .../Modules/Avatars/Http/Favicon/Get.php | 4 +- .../Platform/Modules/Avatars/Http/QR/Get.php | 1 - .../Modules/Avatars/Http/Screenshots/Get.php | 2 +- .../Platform/Modules/Compute/Base.php | 4 +- .../Http/Databases/Collections/Action.php | 2 +- .../Collections/Attributes/Action.php | 4 +- .../Collections/Documents/Action.php | 6 +- .../Collections/Documents/Create.php | 10 - .../Databases/Collections/Documents/Get.php | 2 +- .../Collections/Documents/Upsert.php | 7 +- .../Databases/Collections/Documents/XList.php | 2 +- .../Databases/Collections/Indexes/Action.php | 2 +- .../Http/Databases/Collections/Usage/Get.php | 1 + .../Databases/Http/Databases/Logs/XList.php | 8 +- .../Http/Databases/Transactions/Action.php | 4 +- .../Databases/Http/Databases/Usage/Get.php | 1 + .../Databases/Http/Databases/Usage/XList.php | 1 + .../Databases/Http/TablesDB/Logs/XList.php | 8 +- .../Http/VectorsDB/Embeddings/Text/Create.php | 2 +- .../Modules/Databases/Workers/Databases.php | 2 +- .../Functions/Http/Deployments/XList.php | 2 +- .../Functions/Http/Executions/Create.php | 46 +--- .../Functions/Http/Functions/Update.php | 4 - .../Modules/Functions/Http/Usage/Get.php | 1 + .../Modules/Functions/Http/Usage/XList.php | 2 + .../Functions/Http/Variables/Delete.php | 6 +- .../Modules/Functions/Http/Variables/Get.php | 5 - .../Functions/Http/Variables/Update.php | 2 +- .../Modules/Functions/Workers/Builds.php | 43 +--- .../Modules/Functions/Workers/Screenshots.php | 4 +- .../Health/Http/Health/Certificate/Get.php | 2 +- .../Health/Http/Health/Queue/Failed/Get.php | 2 + .../Modules/Projects/Http/DevKeys/Delete.php | 2 +- .../Modules/Projects/Http/DevKeys/Get.php | 2 +- .../Modules/Projects/Http/DevKeys/Update.php | 2 +- .../Modules/Projects/Http/Projects/XList.php | 2 +- .../Platform/Modules/Proxy/Action.php | 4 +- .../Proxy/Http/Rules/Redirect/Create.php | 3 +- .../Modules/Sites/Http/Deployments/XList.php | 2 +- .../Modules/Sites/Http/Sites/Update.php | 4 - .../Platform/Modules/Sites/Http/Usage/Get.php | 1 + .../Modules/Sites/Http/Usage/XList.php | 2 + .../Modules/Sites/Http/Variables/Delete.php | 6 +- .../Modules/Sites/Http/Variables/Get.php | 5 - .../Modules/Sites/Http/Variables/Update.php | 2 +- .../Storage/Http/Buckets/Files/Create.php | 22 +- .../Http/Buckets/Files/Preview/Get.php | 4 +- .../Storage/Http/Buckets/Files/Update.php | 2 +- .../Modules/Storage/Http/Buckets/XList.php | 9 +- .../Modules/Storage/Http/Usage/Get.php | 1 + .../Modules/Storage/Http/Usage/XList.php | 2 + .../Modules/VCS/Http/GitHub/Callback/Get.php | 8 +- .../Modules/VCS/Http/GitHub/Deployment.php | 2 +- .../Modules/VCS/Http/Installations/Get.php | 2 +- .../Repositories/Branches/XList.php | 6 +- .../Repositories/Contents/Get.php | 2 +- .../Installations/Repositories/Create.php | 2 +- .../Repositories/Detections/Create.php | 2 +- .../Http/Installations/Repositories/Get.php | 6 +- src/Appwrite/Platform/Tasks/Install.php | 24 +- src/Appwrite/Platform/Tasks/Interval.php | 47 ---- src/Appwrite/Platform/Tasks/SDKs.php | 4 +- src/Appwrite/Platform/Tasks/ScheduleBase.php | 2 +- .../Platform/Tasks/ScheduleFunctions.php | 10 +- src/Appwrite/Platform/Tasks/Screenshot.php | 3 - src/Appwrite/Platform/Tasks/Upgrade.php | 3 - src/Appwrite/Platform/Workers/Audits.php | 2 +- .../Platform/Workers/Certificates.php | 2 +- src/Appwrite/Platform/Workers/Deletes.php | 5 +- src/Appwrite/Platform/Workers/Executions.php | 2 +- src/Appwrite/Platform/Workers/Functions.php | 16 +- src/Appwrite/Platform/Workers/Mails.php | 2 +- src/Appwrite/Platform/Workers/Messaging.php | 13 +- src/Appwrite/Platform/Workers/Migrations.php | 5 +- .../Platform/Workers/StatsResources.php | 2 +- src/Appwrite/Platform/Workers/StatsUsage.php | 2 +- src/Appwrite/Platform/Workers/Webhooks.php | 2 +- src/Appwrite/SDK/Specification/Format.php | 23 +- .../SDK/Specification/Format/OpenAPI3.php | 51 ++-- .../SDK/Specification/Format/Swagger2.php | 41 ++- .../Utopia/Database/Validator/Attributes.php | 6 +- .../Database/Validator/Queries/Webhooks.php | 16 +- src/Appwrite/Utopia/Fetch/BodyMultipart.php | 4 +- src/Appwrite/Utopia/Request/Filter.php | 8 +- src/Appwrite/Utopia/Request/Filters/V20.php | 2 +- src/Appwrite/Utopia/Response/Filters/V16.php | 2 +- src/Appwrite/Vcs/Comment.php | 4 +- src/Executor/Executor.php | 6 +- tests/e2e/Client.php | 2 +- tests/e2e/General/UsageTest.php | 2 - tests/e2e/Scopes/ApiVectorsDB.php | 110 -------- tests/e2e/Services/Account/AccountBase.php | 2 +- .../Account/AccountConsoleClientTest.php | 2 +- .../Account/AccountCustomClientTest.php | 4 +- .../e2e/Services/Databases/DatabasesBase.php | 10 - .../Databases/Transactions/ACIDBase.php | 1 - .../Services/GraphQL/FunctionsClientTest.php | 6 +- .../Services/GraphQL/FunctionsServerTest.php | 6 +- .../e2e/Services/GraphQL/Legacy/AuthTest.php | 1 - .../Services/GraphQL/StorageClientTest.php | 2 +- .../Services/GraphQL/StorageServerTest.php | 2 +- .../Services/GraphQL/TablesDB/AuthTest.php | 1 - .../e2e/Services/GraphQL/TeamsServerTest.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 44 ---- ...altimeCustomClientQueryTestWithMessage.php | 2 +- .../Realtime/RealtimeCustomClientTest.php | 6 +- .../Services/Realtime/RealtimeQueryBase.php | 52 ++-- .../Services/Sites/SitesCustomServerTest.php | 18 -- .../Tokens/TokensConsoleClientTest.php | 4 - tests/e2e/Traits/DatabaseFixture.php | 239 ------------------ tests/extensions/Async/Eventually.php | 2 +- tests/extensions/RetrySubscriber.php | 8 - .../unit/Messaging/MessagingChannelsTest.php | 2 - tests/unit/Network/Validators/DNSTest.php | 5 +- .../Platform/Modules/Installer/ModuleTest.php | 10 +- .../Modules/Installer/Runtime/StateTest.php | 14 +- .../Installer/Validator/AppDomainTest.php | 1 - tests/unit/URL/URLTest.php | 11 - .../Database/Query/RuntimeQueryTest.php | 4 +- tests/unit/Utopia/RequestTest.php | 1 - tests/unit/Utopia/ResponseTest.php | 1 - 157 files changed, 378 insertions(+), 1129 deletions(-) delete mode 100644 tests/e2e/Scopes/ApiVectorsDB.php delete mode 100644 tests/e2e/Traits/DatabaseFixture.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..4b2d7a31b8 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -133,9 +133,6 @@ $createSession = function (string $userId, string $secret, Request $request, Res }); $provider = match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_VERIFICATION, - TOKEN_TYPE_RECOVERY, - TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, TOKEN_TYPE_OAUTH2 => $oauthProvider, @@ -335,15 +332,15 @@ Http::post('/v1/account') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -837,7 +834,7 @@ Http::patch('/v1/account/sessions/:sessionId') throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } - if (!empty($provider) && $className !== null && \class_exists($className)) { + if (!empty($provider) && \class_exists($className)) { $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; @@ -1604,7 +1601,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email + if ($user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email if (empty($email)) { $failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.'); } @@ -1621,7 +1618,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is a user with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $userWithEmail = $dbForProject->findOne('users', [ Query::equal('email', [$email]), ]); @@ -1634,7 +1631,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is an identity with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $identityWithMatchingEmail = $dbForProject->findOne('identities', [ Query::equal('providerEmail', [$email]), ]); @@ -1646,7 +1643,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // Last option -> create the user + if ($user->isEmpty()) { // Last option -> create the user $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -1679,15 +1676,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1820,15 +1817,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1954,7 +1951,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')); } - if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) { + if (isset($sessionUpgrade) && isset($session)) { foreach ($user->getAttribute('targets', []) as $target) { if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) { continue; @@ -2178,15 +2175,15 @@ Http::post('/v1/account/tokens/magic-url') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2488,15 +2485,15 @@ Http::post('/v1/account/tokens/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3397,15 +3394,15 @@ Http::patch('/v1/account/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3442,7 +3439,7 @@ Http::patch('/v1/account/email') */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $email))); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -3531,7 +3528,7 @@ Http::patch('/v1/account/phone') */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $phone))); } $dbForProject->purgeCachedDocument('users', $user->getId()); diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index 2a0012bd30..58c6a2c29e 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -482,7 +482,6 @@ Http::post('/v1/messaging/providers/msg91') $enabled === true && \array_key_exists('senderId', $credentials) && \array_key_exists('authKey', $credentials) - && \array_key_exists('from', $options) ) { $enabled = true; } else { @@ -3207,10 +3206,6 @@ Http::post('/v1/messaging/messages/email') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - $mergedTargets = \array_merge($targets, $cc, $bcc); if (!empty($mergedTargets)) { @@ -3386,10 +3381,6 @@ Http::post('/v1/messaging/messages/sms') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -3527,10 +3518,6 @@ Http::post('/v1/messaging/messages/push') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -4660,7 +4647,7 @@ Http::delete('/v1/messaging/messages/:messageId') if (!empty($scheduleId)) { try { $dbForPlatform->deleteDocument('schedules', $scheduleId); - } catch (Exception) { + } catch (\Throwable) { // Ignore } } diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 4c541d2817..7338197511 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -51,7 +51,8 @@ function getDatabaseTransferResourceServices(string $databaseType) DATABASE_TYPE_LEGACY, DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, - DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), }; } diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index 054a7c8f0d..544beade77 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -113,11 +113,12 @@ Http::get('/v1/project/usage') $factor = match ($period) { '1h' => 3600, '1d' => 86400, + default => throw new \LogicException('Unsupported period: ' . $period), }; $limit = match ($period) { '1h' => (new DateTime($startDate))->diff(new DateTime($endDate))->days * 24, - '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days + '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days, }; $format = match ($period) { diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..36fd176a15 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -321,7 +321,6 @@ Http::patch('/v1/projects/:projectId/auth/:method') $project = $dbForPlatform->getDocument('projects', $projectId); $auth = Config::getParam('auth')[$method] ?? []; $authKey = $auth['key'] ?? ''; - $status = ($status === '1' || $status === 'true' || $status === 1 || $status === true); if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index a8875fc442..57c5854422 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1535,7 +1535,7 @@ Http::patch('/v1/users/:userId/email') Query::equal('identifier', [$email]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1600,7 +1600,7 @@ Http::patch('/v1/users/:userId/email') */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { if (\strlen($email) !== 0) { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $email])); $oldTarget->setAttribute('identifier', $email); @@ -1681,7 +1681,7 @@ Http::patch('/v1/users/:userId/phone') Query::equal('identifier', [$number]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1696,7 +1696,7 @@ Http::patch('/v1/users/:userId/phone') */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { if ($number !== '') { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number])); $oldTarget->setAttribute('identifier', $number); @@ -2842,6 +2842,7 @@ Http::get('/v1/users/usage') $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..06ed676a76 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -67,7 +67,7 @@ Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } @@ -200,12 +200,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId)); } - if ($deployment->getAttribute('resourceType', '') === 'functions') { - $type = 'function'; - } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { - $type = 'site'; - } - if ($deployment->isEmpty()) { $resourceType = $rule->getAttribute('deploymentResourceType', ''); $resourceId = $rule->getAttribute('deploymentResourceId', ''); @@ -215,6 +209,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $exception; } + if ($deployment->getAttribute('resourceType', '') === 'functions') { + $type = 'function'; + } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { + $type = 'site'; + } else { + throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown deployment resource type', view: $errorView); + } + $resource = $type === 'function' ? $authorization->skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) : $authorization->skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', ''))); @@ -302,13 +304,13 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } - $body = $swooleRequest->getContent() ?? ''; + $body = $swooleRequest->getContent() ?: ''; $method = $swooleRequest->server['request_method']; $requestHeaders = $request->getHeaders(); if ($resource->isEmpty() || !$resource->getAttribute('enabled')) { - if ($type === 'functions') { + if ($type === 'function') { throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView); } else { throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView); @@ -330,7 +332,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $runtime = match ($type) { 'function' => $runtimes[$resource->getAttribute('runtime')] ?? null, 'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, - default => null }; // Static site enforced runtime @@ -459,10 +460,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -678,9 +679,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($logs) && \strlen($logs) > $maxLogLength) { $warningMessage = "[WARNING] Logs truncated. The output exceeded {$maxLogLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxLogLength - $warningLength); - $logs = $warningMessage . ($maxContentLength > 0 ? \substr($logs, -$maxContentLength) : ''); + $maxContentLength = $maxLogLength - \strlen($warningMessage); + $logs = $warningMessage . \substr($logs, -$maxContentLength); } // Truncate errors if they exceed the limit @@ -689,9 +689,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($errors) && \strlen($errors) > $maxErrorLength) { $warningMessage = "[WARNING] Errors truncated. The output exceeded {$maxErrorLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxErrorLength - $warningLength); - $errors = $warningMessage . ($maxContentLength > 0 ? \substr($errors, -$maxContentLength) : ''); + $maxContentLength = $maxErrorLength - \strlen($warningMessage); + $errors = $warningMessage . \substr($errors, -$maxContentLength); } /** Update execution status */ $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; @@ -719,14 +718,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $th; } } finally { - if ($type === 'function' || $type === 'site') { - $bus->dispatch(new ExecutionCompleted( - execution: $execution->getArrayCopy(), - project: $project->getArrayCopy(), - spec: $spec, - resource: $resource->getArrayCopy(), - )); - } + $bus->dispatch(new ExecutionCompleted( + execution: $execution->getArrayCopy(), + project: $project->getArrayCopy(), + spec: $spec, + resource: $resource->getArrayCopy(), + )); } $execution->setAttribute('logs', ''); @@ -774,20 +771,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S deployment: $deployment->getArrayCopy(), )); - /* cleanup */ - if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { - $resourceType = $type === 'function' - ? RESOURCE_TYPE_FUNCTIONS - : RESOURCE_TYPE_SITES; - - $queueForDeletes - ->setProject($project) - ->setResourceType($resourceType) - ->setResource($resource->getSequence()) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } - return true; } elseif ($type === 'api') { return false; @@ -852,7 +835,7 @@ Http::init() /* * Appwrite Router */ - $hostname = $request->getHostname() ?? ''; + $hostname = $request->getHostname(); $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { @@ -1499,9 +1482,9 @@ Http::error() ->setParam('development', Http::isDevelopment()) ->setParam('projectName', $project->getAttribute('name')) ->setParam('projectURL', $project->getAttribute('url')) - ->setParam('message', $output['message'] ?? '') - ->setParam('type', $output['type'] ?? '') - ->setParam('code', $output['code'] ?? '') + ->setParam('message', $output['message']) + ->setParam('type', $output['type']) + ->setParam('code', $output['code']) ->setParam('trace', $output['trace'] ?? []) ->setParam('exception', $error); @@ -1616,7 +1599,7 @@ Http::get('/.well-known/acme-challenge/*') throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND, 'Unknown path'); } - if (!\substr($absolute, 0, \strlen($base)) === $base) { + if (\substr($absolute, 0, \strlen($base)) !== $base) { throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, 'Invalid path'); } @@ -1695,7 +1678,7 @@ Http::get('/_appwrite/authorize') ->inject('previewHostname') ->action(function (Request $request, Response $response, string $previewHostname) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 99713af430..4e92b3482d 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -251,7 +251,7 @@ Http::get('/v1/mock/github/callback') $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bba00bede1..fe3fc1d8fe 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -46,7 +46,7 @@ use Utopia\Validator\WhiteList; $parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) { preg_match_all('/{(.*?)}/', $label, $matches); - foreach ($matches[1] ?? [] as $pos => $match) { + foreach ($matches[1] as $pos => $match) { $find = $matches[0][$pos]; $parts = explode('.', $match); @@ -54,8 +54,8 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose"); } - $namespace = $parts[0] ?? ''; - $replace = $parts[1] ?? ''; + $namespace = $parts[0]; + $replace = $parts[1]; $params = match ($namespace) { 'user' => (array) $user, @@ -263,8 +263,7 @@ Http::init() $userClone->setAttribute('type', match ($apiKey->getType()) { API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT, API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, - API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, - default => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_ORGANIZATION, }); $auditContext->user = $userClone; } @@ -385,7 +384,7 @@ Http::init() } // Step 6: Update project and user last activity - if (! $project->isEmpty() && $project->getId() !== 'console') { + if ($project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ @@ -415,9 +414,6 @@ Http::init() } // Steps 7-9: Access Control - Method, Namespace and Scope Validation - /** - * @var ?Method $method - */ $method = $route->getLabel('sdk', false); // Take the first method if there's more than one, @@ -646,7 +642,7 @@ Http::init() if (! empty($data) && ! $cacheLog->isEmpty()) { $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); - $type = $parts[0] ?? null; + $type = $parts[0]; if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) { $bucketId = $parts[1] ?? null; @@ -937,7 +933,7 @@ Http::shutdown() } $auditUser = $auditContext->user; - if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + if (! empty($auditContext->resource) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 0c1dbad923..c0219fa816 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -85,7 +85,7 @@ return function (Container $container): void { return $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -139,7 +139,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (!\in_array($sdk, $sdks, true)) { diff --git a/app/init/registers.php b/app/init/registers.php index c07bc9da8b..54c0053a33 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -71,7 +71,7 @@ $register->set('logger', function () { $providerConfig = match ($providerName) { 'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',], - 'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''], + 'logowl' => ['ticket' => $configChunks[0], 'host' => ''], default => ['key' => $providerConfig], }; } @@ -249,11 +249,11 @@ $register->set('pools', function () { $poolSize = max(1, (int)($instanceConnections / $workerCount)); foreach ($connections as $key => $connection) { - $type = $connection['type'] ?? ''; - $multiple = $connection['multiple'] ?? false; - $schemes = $connection['schemes'] ?? []; + $type = $connection['type']; + $multiple = $connection['multiple']; + $schemes = $connection['schemes']; $config = []; - $dsns = explode(',', $connection['dsns'] ?? ''); + $dsns = explode(',', $connection['dsns']); foreach ($dsns as &$dsn) { $dsn = explode('=', $dsn); $name = ($multiple) ? $key . '_' . $dsn[0] : $key; @@ -318,7 +318,7 @@ $register->set('pools', function () { )); }); }, - 'redis' => function () use ($dsnHost, $dsnPort, $dsnPass) { + default => function () use ($dsnHost, $dsnPort, $dsnPass) { $redis = new \Redis(); @$redis->pconnect($dsnHost, (int)$dsnPort); if ($dsnPass) { @@ -328,7 +328,6 @@ $register->set('pools', function () { return $redis; }, - default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid scheme'), }; $poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool(); diff --git a/app/init/resources.php b/app/init/resources.php index d1bb7584bf..29506bfc9c 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -266,7 +266,7 @@ function getDevice(string $root, string $connection = ''): Device return new Local($root); } } else { - switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL) ?? '')) { + switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL))) { case Storage::DEVICE_LOCAL: default: return new Local($root); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 3f6196c460..7d1731b80d 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -375,7 +375,7 @@ return function (Container $container): void { return $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -478,14 +478,10 @@ return function (Container $container): void { } // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies - if ($response) { // if in http context - add debug header - $response->addHeader('X-Debug-Fallback', 'false'); - } + $response->addHeader('X-Debug-Fallback', 'false'); if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { - if ($response) { - $response->addHeader('X-Debug-Fallback', 'true'); - } + $response->addHeader('X-Debug-Fallback', 'true'); $fallback = $request->getHeader('x-fallback-cookies', ''); $fallback = \json_decode($fallback, true); $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); @@ -1084,7 +1080,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (! in_array($sdk, $sdks)) { diff --git a/app/init/worker/message.php b/app/init/worker/message.php index c505d4cb3a..dfe6af9bd9 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -368,7 +368,7 @@ return function (Container $container): void { $log->addTag('code', $error->getCode()); $log->addTag('verboseType', \get_class($error)); - $log->addTag('projectId', $project->getId() ?? ''); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); diff --git a/app/worker.php b/app/worker.php index 7cc34f397c..12b822c4eb 100644 --- a/app/worker.php +++ b/app/worker.php @@ -129,7 +129,7 @@ $worker $log->setAction('appwrite-queue-' . $queueName); $log->addTag('verboseType', get_class($error)); $log->addTag('code', $error->getCode()); - $log->addTag('projectId', $project->getId() ?? 'n/a'); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); $log->addExtra('trace', $error->getTraceAsString()); diff --git a/phpstan.neon b/phpstan.neon index 85d18fd44d..0b8761c19e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 3 + level: 4 tmpDir: .phpstan-cache paths: - src diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 7ff16fcb78..6e0da14437 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -11,11 +11,6 @@ class Etsy extends OAuth2 */ private string $endpoint = 'https://api.etsy.com/v3/public'; - /** - * @var string - */ - private string $version = '2022-07-14'; - /** * @var array */ diff --git a/src/Appwrite/Auth/OAuth2/Podio.php b/src/Appwrite/Auth/OAuth2/Podio.php index 0b1f35414b..6a977da854 100644 --- a/src/Appwrite/Auth/OAuth2/Podio.php +++ b/src/Appwrite/Auth/OAuth2/Podio.php @@ -121,7 +121,7 @@ class Podio extends OAuth2 { $user = $this->getUser($accessToken); - return \strval($user['user_id']) ?? ''; + return \strval($user['user_id']); } /** diff --git a/src/Appwrite/Auth/OAuth2/Zoom.php b/src/Appwrite/Auth/OAuth2/Zoom.php index 9dad22212a..a4967741a9 100644 --- a/src/Appwrite/Auth/OAuth2/Zoom.php +++ b/src/Appwrite/Auth/OAuth2/Zoom.php @@ -11,11 +11,6 @@ class Zoom extends OAuth2 */ private string $endpoint = 'https://zoom.us'; - /** - * @var string - */ - private string $version = '2022-03-26'; - /** * @var array */ diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php index 3b09839bd1..b047e5dd2f 100644 --- a/src/Appwrite/Auth/Validator/PersonalData.php +++ b/src/Appwrite/Auth/Validator/PersonalData.php @@ -59,7 +59,7 @@ class PersonalData extends Password return false; } - if ($this->email && strpos($password, explode('@', $this->email)[0] ?? '') !== false) { + if ($this->email && strpos($password, explode('@', $this->email)[0]) !== false) { return false; } diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php index 87699aaeba..e7993d6927 100644 --- a/src/Appwrite/Docker/Compose/Service.php +++ b/src/Appwrite/Docker/Compose/Service.php @@ -21,7 +21,7 @@ class Service array_walk($ports, function (&$value, $key) { $split = explode(':', $value); $this->service['ports'][ - (isset($split[0])) ? $split[0] : '' + $split[0] ] = (isset($split[1])) ? $split[1] : ''; }); diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index af5e4f11e2..7e44a6c5cf 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -15,7 +15,7 @@ class Env foreach ($data as &$row) { $row = explode('=', $row, 2); - $key = (isset($row[0])) ? trim($row[0]) : null; + $key = trim($row[0]); $value = (isset($row[1])) ? (function (string $v): string { $v = trim($v); if ( diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index fae2d0e843..357442a07c 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -459,7 +459,7 @@ class Event /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && \str_starts_with($parts[3], '['); $hasSubSubResource = $count > 5 && \str_starts_with($parts[5], '[') && $hasSubResource; diff --git a/src/Appwrite/Event/Validator/Event.php b/src/Appwrite/Event/Validator/Event.php index a3605e4df5..7a4f4fbcf8 100644 --- a/src/Appwrite/Event/Validator/Event.php +++ b/src/Appwrite/Event/Validator/Event.php @@ -44,7 +44,7 @@ class Event extends Validator /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && ($events[$type]['$resource'] ?? false) && ($events[$type][$parts[2]]['$resource'] ?? false); $hasSubSubResource = $count > 5 && $hasSubResource && ($events[$type][$parts[2]][$parts[4]]['$resource'] ?? false); @@ -61,9 +61,6 @@ class Event extends Validator if ($hasSubSubResource) { $subSubType = $parts[4]; $subSubResource = $parts[5]; - if ($count === 8) { - $attribute = $parts[7]; - } } if ($hasSubResource && !$hasSubSubResource) { diff --git a/src/Appwrite/Event/Webhook.php b/src/Appwrite/Event/Webhook.php index f6d16c8b14..5cd773a18f 100644 --- a/src/Appwrite/Event/Webhook.php +++ b/src/Appwrite/Event/Webhook.php @@ -24,7 +24,7 @@ class Webhook extends Event public function trimPayload(): array { $trimmed = parent::trimPayload(); - if (isset($this->context)) { + if (!empty($this->context)) { $trimmed['context'] = []; } return $trimmed; diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index 53474b855a..55810fd74e 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -91,26 +91,20 @@ class Mapper } } - $responses = $method->getResponses() ?? []; + $responses = $method->getResponses(); - // If responses is an array, map each response to its model - if (\is_array($responses)) { - $models = []; - foreach ($responses as $response) { - $modelName = $response->getModel(); + // Map each response to its model + $models = []; + foreach ($responses as $response) { + $modelName = $response->getModel(); - if (\is_array($modelName)) { - foreach ($modelName as $name) { - $models[] = self::$models[$name]; - } - } else { - $models[] = self::$models[$modelName]; + if (\is_array($modelName)) { + foreach ($modelName as $name) { + $models[] = self::$models[$name]; } + } else { + $models[] = self::$models[$modelName]; } - } else { - // If single response, get its model and wrap in array - $modelName = $responses->getModel(); - $models = [self::$models[$modelName]]; } foreach ($models as $model) { diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f1d806bcc5..6252ea4d44 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -370,15 +370,8 @@ class Realtime extends MessagingAdapter $params = $getQueryParam($paramKey); if (\array_key_exists($paramKey, $reservedParamExpectedTypes) && $params !== null) { - $expectedType = $reservedParamExpectedTypes[$paramKey]; - $isExpectedType = match ($expectedType) { - 'array' => \is_array($params), - 'string' => \is_string($params), - default => false, - }; - // If the value matches the expected type dont use it the queries - if ($isExpectedType) { + if (\is_string($params)) { $params = null; } } diff --git a/src/Appwrite/OpenSSL/OpenSSL.php b/src/Appwrite/OpenSSL/OpenSSL.php index 787feb0904..89c52f069e 100644 --- a/src/Appwrite/OpenSSL/OpenSSL.php +++ b/src/Appwrite/OpenSSL/OpenSSL.php @@ -16,7 +16,7 @@ class OpenSSL * @param string $aad * @param int $tag_length * - * @return string + * @return string|false */ public static function encrypt($data, $method, $key, $options = 0, $iv = '', ?string &$tag = null, $aad = '', $tag_length = 16) { diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 8aaaf621bb..e7e9008e3b 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -240,9 +240,7 @@ class Install extends Action $inputValue = trim($inputValue); } if ($storedValue !== $inputValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -262,16 +260,12 @@ class Install extends Action $incomingHash = $state->hashSensitiveValue($incomingValue); if (isset($stored[$hashField])) { if (!hash_equals((string) $stored[$hashField], $incomingHash)) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } } elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -430,7 +424,7 @@ class Install extends Action private function deriveNameFromEmail(string $email): string { $parts = explode('@', $email); - $username = $parts[0] ?? ''; + $username = $parts[0]; $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username); return ucfirst($cleaned); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php index d6ffa64c8f..204ace077c 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -45,7 +45,7 @@ class Status extends Action } $data = $state->readProgressFile($installId); - if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) { + if (isset($data['payload']) && is_array($data['payload'])) { unset( $data['payload']['opensslKey'], $data['payload']['assistantOpenAIKey'], @@ -54,7 +54,7 @@ class Status extends Action ); } // Strip sensitive data from step details - if (is_array($data) && isset($data['details']) && is_array($data['details'])) { + if (isset($data['details']) && is_array($data['details'])) { foreach ($data['details'] as $stepKey => &$stepDetails) { if (is_array($stepDetails)) { unset($stepDetails['sessionSecret'], $stepDetails['trace']); diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php index 99db12dfed..978407894e 100644 --- a/src/Appwrite/Platform/Installer/Runtime/Config.php +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -222,7 +222,7 @@ final class Config */ public function setEnabledDatabases(array $value): void { - $filtered = array_values(array_filter($value, fn ($v) => is_string($v) && $v !== '')); + $filtered = array_values(array_filter($value, fn ($v) => $v !== '')); if (!empty($filtered)) { $this->enabledDatabases = $filtered; } diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php index 75efd7027c..3cbcc51fa6 100644 --- a/src/Appwrite/Platform/Installer/Runtime/State.php +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -19,13 +19,11 @@ class State private const int PORT_MIN = 1; private const int PORT_MAX = 65535; - private array $paths; private bool $bootstrapped = false; private int $lastStaleLockClearAt = 0; - public function __construct(array $paths) + public function __construct() { - $this->paths = $paths; } public function buildConfig(array $overrides = [], bool $useEnv = true): Config @@ -180,7 +178,7 @@ class State if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 99ec9e65d2..38d61b7d24 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -60,7 +60,7 @@ class Server { $this->initPaths(); - $this->state = new State($this->paths); + $this->state = new State(); if (PHP_SAPI === 'cli') { $this->runCli(); diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php index f631015654..5d18b5214a 100644 --- a/src/Appwrite/Platform/Installer/Validator/AppDomain.php +++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php @@ -47,7 +47,7 @@ class AppDomain extends Validator if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php index f8e7a35b05..d0c600192b 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php @@ -86,10 +86,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php index 37776a3466..ad74d6c192 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php @@ -90,10 +90,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index b6cc408dde..a41d0f81da 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -98,7 +98,7 @@ class Get extends Action $doc->strictErrorChecking = false; @$doc->loadHTML($res->getBody()); - $links = $doc->getElementsByTagName('link') ?? []; + $links = $doc->getElementsByTagName('link'); $outputHref = ''; $outputExt = ''; $space = 0; @@ -128,7 +128,7 @@ class Get extends Action case 'jpeg': $size = \explode('x', \strtolower($sizes)); - $sizeWidth = (int) ($size[0] ?? 0); + $sizeWidth = (int) $size[0]; $sizeHeight = (int) ($size[1] ?? 0); if (($sizeWidth * $sizeHeight) >= $space) { diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php index 27fd8708d9..f3448f5264 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php @@ -60,7 +60,6 @@ class Get extends Action public function action(string $text, int $size, int $margin, bool $download, Response $response) { - $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true); $options = new QROptions([ 'addQuietzone' => true, 'quietzoneSize' => $margin, diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php index 2df12b17d1..c43c0fc4bf 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -105,7 +105,7 @@ class Get extends Action $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); // Convert indexed array to empty array (should not happen due to Assoc validator) - if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { + if (count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { $headers = []; } diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index f388e46f83..85dfec3cfd 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -68,7 +68,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $function->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -169,7 +169,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $site->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index 4afab449c0..1f730fa543 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -12,7 +12,7 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; + private string $context = COLLECTIONS; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php index 0d562a2894..1606c7ab40 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php @@ -26,9 +26,9 @@ use Utopia\Validator\Range; abstract class Action extends UtopiaAction { /** - * @var string|null The current context (either 'column' or 'attribute') + * @var string The current context (either 'column' or 'attribute') */ - private ?string $context = ATTRIBUTES; + private string $context = ATTRIBUTES; /** * Get the correct response model. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 91dd9c603c..8100a2c51b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -14,10 +14,10 @@ use Utopia\Database\Validator\Authorization; abstract class Action extends DatabasesAction { /** - * @var string|null The current context (either 'row' or 'document') + * @var string The current context (either 'row' or 'document') */ - private ?string $context = DOCUMENTS; - private ?string $databaseType = DATABASE_TYPE_LEGACY; + private string $context = DOCUMENTS; + private string $databaseType = DATABASE_TYPE_LEGACY; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 24cba578a9..633a2bbc86 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -293,16 +293,6 @@ class Create extends Action throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); } - if ($permission === Database::PERMISSION_UPDATE) { - $validDocument = $authorization->isValid( - new Input($permission, $document->getUpdate()) - ); - $valid = $validCollection || $validDocument; - if ($documentSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); - } - } - $relationships = \array_filter( $collection->getAttribute('attributes', []), fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index b48df136ee..06f0e9cf1c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -100,7 +100,7 @@ class Get extends Action } try { - $selects = Query::groupByType($queries)['selections'] ?? []; + $selects = Query::groupByType($queries)['selections']; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index ef89b80e97..fb3d414097 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -353,12 +353,7 @@ class Upsert extends Action $collectionsCache = []; if (empty($upserted[0])) { - if ($transactionId !== null) { - // For transactions, get the document with transaction changes applied - $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); - } else { - $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); - } + $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); } $document = $upserted[0]; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index aeee280615..bb7ef74761 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -127,7 +127,7 @@ class XList extends Action } try { - $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); + $hasSelects = ! empty(Query::groupByType($queries)['selections']); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php index 400d716e41..251e493cb6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php @@ -10,7 +10,7 @@ abstract class Action extends UtopiaAction /** * The current API context (either 'columnIndex' or 'index'). */ - private ?string $context = INDEX; + private string $context = INDEX; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php index 37213f1061..bea367af36 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php @@ -119,6 +119,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php index 1ed7e6a63f..a13c6c4903 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -9,7 +10,6 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -103,9 +103,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index 91bc1a3ccf..ccf9632fef 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -9,8 +9,8 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; - private ?string $databaseType = LEGACY; + private string $context = COLLECTIONS; + private string $databaseType = LEGACY; public function getDatabaseType(): string { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php index 18e6fd7a8b..240e7d400c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php @@ -144,6 +144,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php index b8cb774a3e..db73954e7f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php @@ -133,6 +133,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php index 81822df208..ccb421b36d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php @@ -2,13 +2,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -97,9 +97,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php index d9b378774b..8a7137e38b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php @@ -98,7 +98,7 @@ class Create extends CreateDocumentAction $error = ''; try { $embedResult = $embeddingAgent->embed($text); - $embedding = $embedResult['embedding'] ?? []; + $embedding = $embedResult['embedding']; $totalDuration += $embedResult['totalDuration'] ?? 0; $totalTokens += $embedResult['tokensProcessed'] ?? 0; } catch (\Exception $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index a50e8f8bdf..39902aea53 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -54,7 +54,7 @@ class Databases extends Action */ public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php index fef0708931..e8e9ea9a18 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 72474b03f9..6afaef95ed 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions; use Ahc\Jwt\JWT; -use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Extend\Exception; @@ -101,8 +100,6 @@ class Create extends Base ->inject('executor') ->inject('platform') ->inject('authorization') - ->inject('queueForDeletes') - ->inject('executionsRetentionCount') ->callback($this->action(...)); } @@ -129,8 +126,6 @@ class Create extends Base Executor $executor, array $platform, Authorization $authorization, - DeleteEvent $queueForDeletes, - int $executionsRetentionCount, ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -145,21 +140,8 @@ class Create extends Base } } - /** - * @var array $headers - */ - $assocParams = ['headers']; - foreach ($assocParams as $assocParam) { - if (!empty('headers') && !is_array($$assocParam)) { - $$assocParam = \json_decode($$assocParam, true); - } - } - - $booleanParams = ['async']; - foreach ($booleanParams as $booleamParam) { - if (!empty($$booleamParam) && !is_bool($$booleamParam)) { - $$booleamParam = $$booleamParam === "true" ? true : false; - } + if (!is_array($headers)) { + $headers = \json_decode($headers, true); } // 'headers' validator @@ -349,15 +331,6 @@ class Create extends Base $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } - if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { - $queueForDeletes - ->setProject($project) - ->setResource($function->getSequence()) - ->setResourceType(RESOURCE_TYPE_FUNCTIONS) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } - $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($execution, Response::MODEL_EXECUTION); return; @@ -370,10 +343,10 @@ class Create extends Base // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -536,15 +509,6 @@ class Create extends Base } } - if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { - $queueForDeletes - ->setProject($project) - ->setResource($function->getSequence()) - ->setResourceType(RESOURCE_TYPE_FUNCTIONS) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } - $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 71fc99a30e..7d6572d336 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -162,10 +162,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - if (empty($runtime)) { $runtime = $function->getAttribute('runtime'); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php index 19476329bf..7016d600cb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php @@ -112,6 +112,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php index 38a95d4469..70b7b8e058 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Functions\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -104,6 +105,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php index 5648596826..f6d77c2a0d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php @@ -77,11 +77,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php index 19c345fbc2..13ce73e751 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php index acb066ca9c..54d7a647a3 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php @@ -85,7 +85,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 87e936a965..286f1c55ee 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -102,7 +102,7 @@ class Builds extends Action ): void { Console::log('Build action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -206,7 +206,7 @@ class Builds extends Action throw new \Exception('Resource not found'); } - if ($isResourceBlocked($project, $resourceKey === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { + if ($isResourceBlocked($project, $resource->getCollection() === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { throw new \Exception('Resource is blocked'); } @@ -226,10 +226,6 @@ class Builds extends Action $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; - if ($resource->getCollection() === 'functions' && \is_null($runtime)) { - throw new \Exception('Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); - } - // Realtime preparation $event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update"; $queueForRealtime @@ -829,7 +825,8 @@ class Builds extends Action Console::log('Runtime creation finished'); - if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { + $latestDeployment = $dbForProject->getDocument('deployments', $deploymentId); + if ($latestDeployment->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); return; @@ -1259,21 +1256,6 @@ class Builds extends Action */ protected function afterBuildSuccess(Realtime $queueForRealtime, Database $dbForProject, Document &$deployment, array $runtime, ?string $adapter): void { - if (! ($queueForRealtime instanceof Realtime)) { - throw new Exception('queueForRealtime must be an instance of Realtime'); - } - if (! ($dbForProject instanceof Database)) { - throw new Exception('dbForProject must be an instance of Database'); - } - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } - if (! is_array($runtime)) { - throw new Exception('runtime must be an array'); - } - if (! is_string($adapter) && ! is_null($adapter)) { - throw new Exception('adapter must be a string or null'); - } } /** @@ -1283,13 +1265,6 @@ class Builds extends Action Document $project, Document $deployment, ): void { - if (! ($project instanceof Document)) { - throw new Exception('project must be an instance of Document'); - } - - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } } protected function getRuntime(Document $resource, string $version): array @@ -1313,6 +1288,7 @@ class Builds extends Action return match ($resource->getCollection()) { 'functions' => $resource->getAttribute('version', 'v2'), 'sites' => 'v5', + default => throw new \Exception('Unsupported resource type "' . $resource->getCollection() . '".'), }; } @@ -1445,11 +1421,10 @@ class Builds extends Action ]); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $previewUrl = match ($resource->getCollection()) { - 'functions' => '', - 'sites' => !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', - default => throw new \Exception('Invalid resource type') - }; + $previewUrl = ''; + if ($resource->getCollection() === 'sites' && !$rule->isEmpty()) { + $previewUrl = "{$protocol}://" . $rule->getAttribute('domain', ''); + } $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 423bf0bd41..e7d4887cbd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -57,7 +57,7 @@ class Screenshots extends Action ): void { Console::log('Screenshot action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -162,7 +162,7 @@ class Screenshots extends Action try { $config = $configs[$key]; - $config['headers'] = \array_merge($config['headers'] ?? [], [ + $config['headers'] = \array_merge($config['headers'], [ 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey ]); $config['sleep'] = 3000; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index 60cf5d00d4..728ffb8b71 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -82,7 +82,7 @@ class Get extends Action } $certificatePayload = @openssl_x509_parse($peerCertificate); - if ($certificatePayload === false || !\is_array($certificatePayload)) { + if ($certificatePayload === false) { throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to parse peer certificate for ' . $domain); } diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 6d77cc6e16..7602de45d3 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -16,6 +16,7 @@ use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Webhook; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -123,6 +124,7 @@ class Get extends Base System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name), }; $failed = $queue->getSize(failed: true); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php index 5329585be3..76df8c2b45 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php @@ -63,7 +63,7 @@ class Delete extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php index 5cb3b0545f..ff4e348c8e 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php @@ -63,7 +63,7 @@ class Get extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php index f3e47f80ba..9704740bc4 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php @@ -66,7 +66,7 @@ class Update extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 8e420e87f2..0d2a951388 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -109,7 +109,7 @@ class XList extends Action } try { - $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $selectQueries = Query::groupByType($queries)['selections']; $filterQueries = Query::groupByType($queries)['filters']; $projects = $this->find($dbForPlatform, $queries, $selectQueries); diff --git a/src/Appwrite/Platform/Modules/Proxy/Action.php b/src/Appwrite/Platform/Modules/Proxy/Action.php index 30ad140530..8baf54c790 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Action.php +++ b/src/Appwrite/Platform/Modules/Proxy/Action.php @@ -164,9 +164,7 @@ class Action extends PlatformAction $validator = new AnyOf($cnameValidators); $validators[] = $validator; - if (\is_null($mainValidator)) { - $mainValidator = $validator; - } + $mainValidator = $validator; } // Ensure at least one of CNAME/A/AAAA record points to our servers properly diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 8a265ba5bb..5964a20772 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -84,7 +84,8 @@ class Create extends Action $collection = match ($resourceType) { 'site' => 'sites', - 'function' => 'functions' + 'function' => 'functions', + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType), }; $resource = $dbForProject->getDocument($collection, $resourceId); if ($resource->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php index a9198f937b..3dccd687ea 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index dd9bedffb5..3c0d090b7b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -164,10 +164,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($site->isEmpty()) { - throw new Exception(Exception::SITE_NOT_FOUND); - } - if (empty($framework)) { $framework = $site->getAttribute('framework'); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a6768462d1..85968c7550 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -121,6 +121,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index a90cb0cab9..636889f6c0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Sites\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -107,6 +108,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php index 703806f1aa..d61c9892cf 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php @@ -67,11 +67,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php index 54522c0ec7..2fcb051996 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php index 99f68a45df..08cdd4ac38 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php @@ -79,7 +79,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index c5f4f3dccd..befc02a1df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -384,14 +384,11 @@ class Create extends Action ->setAttribute('chunksUploaded', $chunksUploaded); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } @@ -431,15 +428,11 @@ class Create extends Action ->setAttribute('metadata', $metadata); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - try { $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } catch (NotFoundException) { @@ -468,8 +461,5 @@ class Create extends Action */ protected function afterCreateSuccess(Document $file) { - if (!($file instanceof Document)) { - throw new Exception('file must be an instance of document'); - } } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f6b6eb25da..4fa5006db8 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -200,7 +200,7 @@ class Get extends Action // when file extension is not provided and the mime type is not one of our supported outputs // we fallback to `jpg` output format - $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type; + $output = empty($type) ? (array_search($mime, $outputs) ?: 'jpg') : $type; } $startTime = \microtime(true); @@ -243,7 +243,7 @@ class Get extends Action $image->crop((int) $width, (int) $height, $gravity); - if (!empty($opacity) || $opacity === 0) { + if (!empty($opacity)) { $image->setOpacity($opacity); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 8e69468170..407f3766df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -130,7 +130,7 @@ class Update extends Action } if (\is_null($permissions)) { - $permissions = $file->getPermissions() ?? []; + $permissions = $file->getPermissions(); } $file->setAttribute('$permissions', $permissions); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php index 8f2cd9bbac..d8e5cd5ad2 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php @@ -143,11 +143,12 @@ class XList extends Action }); foreach ($stats as $stat) { - $bucket = $bucketByStatsId[$stat->getId()]; - - if ($bucket) { - $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); + if (!isset($bucketByStatsId[$stat->getId()])) { + continue; } + + $bucket = $bucketByStatsId[$stat->getId()]; + $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); } } catch (\Throwable) { // Stats may not be available, default to 0 diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php index a7bda355da..10a603f5df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php @@ -109,6 +109,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php index 44fdd54e8c..04eac21754 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Storage\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -92,6 +93,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php index 69da270e19..c5a8d8f43f 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -104,7 +104,7 @@ class Get extends Action $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); @@ -121,11 +121,11 @@ class Get extends Action if (!empty($code)) { $oauth2 = new OAuth2Github(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), ""); - $accessToken = $oauth2->getAccessToken($code) ?? ''; - $refreshToken = $oauth2->getRefreshToken($code) ?? ''; + $accessToken = $oauth2->getAccessToken($code); + $refreshToken = $oauth2->getRefreshToken($code); $accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code))); - $personalSlug = $oauth2->getUserSlug($accessToken) ?? ''; + $personalSlug = $oauth2->getUserSlug($accessToken); $personal = $personalSlug === $owner; } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 6e1db12c28..33d7e984fb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -107,7 +107,7 @@ trait Deployment $activate = true; } - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php index 7bb2dedaf5..4e7b80f5b2 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php @@ -59,7 +59,7 @@ class Get extends Action ) { $installation = $dbForPlatform->getDocument('installations', $installationId); - if ($installation === false || $installation->isEmpty()) { + if ($installation->isEmpty()) { throw new Exception(Exception::INSTALLATION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 4ed4241d25..8ead94b7cb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -73,9 +73,9 @@ class XList extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -83,7 +83,7 @@ class XList extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $branches = $github->listBranches($owner, $repositoryName) ?? []; + $branches = $github->listBranches($owner, $repositoryName); $response->dynamic(new Document([ 'branches' => \array_map(function ($branch) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php index a0dcec8590..89b38e7b79 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php @@ -79,7 +79,7 @@ class Get extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php index 04003812f8..1918e454a4 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php @@ -152,7 +152,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Provider Error: ' . $repository['message']); } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 6295fcd03b..aa7d7ae95c 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -121,7 +121,7 @@ class Create extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php index 52b94cd525..ec135dc96e 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php @@ -73,9 +73,9 @@ class Get extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -97,7 +97,7 @@ class Get extends Action } } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index dd7bed0137..3e11a4060c 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -109,7 +109,7 @@ class Install extends Action file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data); $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); - $oldVersion = $appwrite?->getImageVersion(); + $oldVersion = $appwrite->getImageVersion(); try { $ports = $compose->getService('traefik')->getPorts(); } catch (\Throwable $th) { @@ -122,10 +122,6 @@ class Install extends Action if ($oldVersion) { foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } - $env = $service->getEnvironment()->list(); foreach ($env as $key => $value) { @@ -177,9 +173,6 @@ class Install extends Action // can be detected by the DB service name or _APP_DB_HOST. $existingDatabase = null; foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $svcEnv = $service->getEnvironment()->list(); if (isset($svcEnv['_APP_DB_ADAPTER'])) { $existingDatabase = $svcEnv['_APP_DB_ADAPTER']; @@ -229,8 +222,8 @@ class Install extends Action $assistantExistsInOldCompose = false; if ($existingInstallation) { try { - $assistantService = $compose->getService('appwrite-assistant'); - $assistantExistsInOldCompose = $assistantService !== null; + $compose->getService('appwrite-assistant'); + $assistantExistsInOldCompose = true; } catch (\Throwable) { /* ignore */ } @@ -290,7 +283,7 @@ class Install extends Action continue; } - if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) { + if ($var['name'] === '_APP_DB_ADAPTER' && $data !== '') { $userInput[$var['name']] = $database; continue; } @@ -334,7 +327,7 @@ class Install extends Action @unlink(InstallerServer::INSTALLER_COMPLETE_FILE); - $state = new State([]); + $state = new State(); $state->clearStaleLock(); $installerConfig = $this->readInstallerConfig(); @@ -608,7 +601,7 @@ class Install extends Action $this->copyMongoEntrypointIfNeeded(); } - if (!$noStart && $startIndex <= 2) { + if (!$noStart) { $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); @@ -838,7 +831,7 @@ class Install extends Action 'email' => $email, 'domain' => $domain, 'database' => $database, - 'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null, + 'ip' => ($hostIp !== $domain) ? $hostIp : null, 'os' => php_uname('s') . ' ' . php_uname('r'), 'arch' => php_uname('m'), 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null, @@ -1365,9 +1358,6 @@ class Install extends Action } foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); $host = $env['_APP_DB_HOST'] ?? null; if ($host !== null && in_array($host, $dbServices, true)) { diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index f5502a5986..7308dc003f 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -75,7 +75,6 @@ class Interval extends Action protected function getTasks(): array { $intervalDomainVerification = (int) System::getEnv('_APP_INTERVAL_DOMAIN_VERIFICATION', '120'); // 2 minutes - $intervalCleanupStaleExecutions = (int) System::getEnv('_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS', '300'); // 5 minutes return [ [ @@ -135,50 +134,4 @@ class Interval extends Action Span::add("interval.domainVerification.processed", $processed); Span::add("interval.domainVerification.failed", $failed); } - - private function cleanupStaleExecutions(Database $dbForPlatform, callable $getProjectDB): void - { - $staleThreshold = DatabaseDateTime::addSeconds(new DateTime(), -1200); // 20 minutes ago - - $scanned = 0; - $processed = 0; - $failed = 0; - - $dbForPlatform->foreach( - 'projects', - function (Document $project) use ($getProjectDB, $staleThreshold, &$scanned, &$processed, &$failed) { - try { - $dbForProject = $getProjectDB($project); - - $staleExecutions = $dbForProject->find('executions', [ - Query::equal('status', ['processing']), - Query::lessThan('$createdAt', $staleThreshold), - Query::limit(100), - ]); - - $scanned += \count($staleExecutions); - - if (\count($staleExecutions) === 0) { - return; - } - - foreach ($staleExecutions as $execution) { - $dbForProject->updateDocument('executions', $execution->getId(), new Document(['status' => 'failed', 'errors' => 'Execution timed out'])); - } - - $processed++; - } catch (\Throwable $th) { - $failed++; - } - }, - [ - Query::equal('region', [System::getEnv('_APP_REGION', 'default')]), - Query::limit(100), - ] - ); - - Span::add("interval.cleanupStaleExecutions.scanned", $scanned); - Span::add("interval.cleanupStaleExecutions.processed", $processed); - Span::add("interval.cleanupStaleExecutions.failed", $failed); - } } diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 526ea304de..f96a8e1f99 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -181,7 +181,7 @@ class SDKs extends Action Console::log(''); - if ($createRelease && ! $examplesOnly) { + if ($createRelease) { Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━"); $changelog = $language['changelog'] ?? ''; $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log'; @@ -1146,7 +1146,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (! empty($prListOutput[0])) { $parts = \explode(' ', trim($prListOutput[0]), 2); - $prNumber = $parts[0] ?? ''; + $prNumber = $parts[0]; $prUrl = $parts[1] ?? ''; } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index c55e3d4a6a..9ecd151474 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -102,7 +102,7 @@ abstract class ScheduleBase extends Action $this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate, $isResourceBlocked); }); - while (true) { + for (;;) { try { go(fn () => $this->enqueueResources($dbForPlatform, $getProjectDB)); $this->scheduleTelemetryCount->record(count($this->schedules), ['resourceType' => static::getSupportedResource()]); diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index f867884801..75908c99c7 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -21,8 +21,6 @@ class ScheduleFunctions extends ScheduleBase public const UPDATE_TIMER = 10; // seconds public const ENQUEUE_TIMER = 60; // seconds - private ?float $lastEnqueueUpdate = null; - public static function getName(): string { return 'schedule-functions'; @@ -43,7 +41,10 @@ class ScheduleFunctions extends ScheduleBase $timerStart = \microtime(true); $time = DateTime::now(); - $enqueueDiff = $this->lastEnqueueUpdate === null ? 0 : $timerStart - $this->lastEnqueueUpdate; + // TODO: Track the last enqueue timestamp to subtract ENQUEUE_TIMER drift from + // the time frame. Previously this used $this->lastEnqueueUpdate as a property + // but enabling the assignment broke scheduling, so the diff stays 0. + $enqueueDiff = 0; $timeFrame = DateTime::addSeconds(new \DateTime(), static::ENQUEUE_TIMER - $enqueueDiff); Console::log("Enqueue tick: started at: $time (with diff $enqueueDiff)"); @@ -128,9 +129,6 @@ class ScheduleFunctions extends ScheduleBase $timerEnd = \microtime(true); - // TODO: This was a bug before because it wasn't passed by reference, enabling it breaks scheduling - //$this->lastEnqueueUpdate = $timerStart; - Console::log("Enqueue tick: {$total} executions were enqueued in " . ($timerEnd - $timerStart) . " seconds"); } } diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php index 59e0b11c89..3b50ed7e00 100644 --- a/src/Appwrite/Platform/Tasks/Screenshot.php +++ b/src/Appwrite/Platform/Tasks/Screenshot.php @@ -40,9 +40,6 @@ class Screenshot extends Action throw new \Exception('Invalid JSON in --variables flag'); } } - if ($variables === null) { - throw new \Exception('Invalid JSON in --variables flag'); - } $templates = Config::getParam('templates-site', []); diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index f49674896e..bde73fd05c 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -65,9 +65,6 @@ class Upgrade extends Install $database = null; $compose = new Compose($data); foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); if (isset($env['_APP_DB_ADAPTER'])) { $database = $env['_APP_DB_ADAPTER']; diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index e5a7950945..f6b0345381 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -58,7 +58,7 @@ class Audits extends Action */ public function action(Message $message, callable $getAudit): Commit|NoCommit { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 34234971d9..4d04a3c92c 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -94,7 +94,7 @@ class Certificates extends Action array $plan, ValidatorAuthorization $authorization, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index f4978780a1..e027f9fbc3 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -96,7 +96,7 @@ class Deletes extends Action DeleteEvent $queueForDeletes, callable $getAudit, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); @@ -304,7 +304,8 @@ class Deletes extends Action $collectionId = match ($document->getAttribute('resourceType')) { 'function' => 'functions', 'execution' => 'executions', - 'message' => 'messages' + 'message' => 'messages', + default => throw new \Exception('Unknown resource type: ' . $document->getAttribute('resourceType')), }; try { diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 99e20be035..404b04ce76 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -34,7 +34,7 @@ class Executions extends Action Message $message, Database $dbForProject, ): void { - $executionMessage = Execution::fromArray($message->getPayload() ?? []); + $executionMessage = Execution::fromArray($message->getPayload()); $execution = $executionMessage->execution; if ($execution->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 0899fbacb4..28c298b050 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -68,7 +68,7 @@ class Functions extends Action Executor $executor, callable $isResourceBlocked ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new AppwriteException( @@ -258,7 +258,7 @@ class Functions extends Action jwt: $jwt, event: null, eventData: null, - executionId: $execution->getId() ?? null + executionId: $execution->getId() ); break; } @@ -437,7 +437,7 @@ class Functions extends Action $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; + $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt ?? ''; $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; @@ -488,12 +488,12 @@ class Functions extends Action // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, 'APPWRITE_FUNCTION_EVENT_DATA' => $body, - 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'] ?? '', - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'], + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -688,7 +688,7 @@ class Functions extends Action if (!empty($error)) { throw new AppwriteException( AppwriteException::GENERAL_SERVER_ERROR, - 'Function execution failed: ' . ($error ?: 'No error message provided'), + 'Function execution failed: ' . $error, $errorCode ); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 32de1e50d6..07c1a5242f 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -61,7 +61,7 @@ class Mails extends Action public function action(Message $message, Document $project, Registry $register, Log $log): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index ff5eb2417a..03adebc4b5 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -96,7 +96,7 @@ class Messaging extends Action UsagePublisher $publisherForUsage ): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -257,7 +257,9 @@ class Messaging extends Action $identifiersForProvider = $identifiers[$providerId]; - $adapter = match ($provider->getAttribute('type')) { + $providerType = $provider->getAttribute('type'); + + $adapter = match ($providerType) { MESSAGE_TYPE_SMS => $this->getSmsAdapter($provider), MESSAGE_TYPE_PUSH => $this->getPushAdapter($provider), MESSAGE_TYPE_EMAIL => $this->getEmailAdapter($provider), @@ -269,18 +271,17 @@ class Messaging extends Action $adapter->getMaxMessagesPerRequest() ); - return batch(\array_map(function ($batch) use ($message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { - return function () use ($batch, $message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return batch(\array_map(function ($batch) use ($message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return function () use ($batch, $message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { $deliveredTotal = 0; $deliveryErrors = []; $messageData = clone $message; $messageData->setAttribute('to', $batch); - $data = match ($provider->getAttribute('type')) { + $data = match ($providerType) { MESSAGE_TYPE_SMS => $this->buildSmsMessage($messageData, $provider), MESSAGE_TYPE_PUSH => $this->buildPushMessage($messageData), MESSAGE_TYPE_EMAIL => $this->buildEmailMessage($dbForProject, $messageData, $provider, $deviceForFiles, $project), - default => throw new \Exception('Provider with the requested ID is of the incorrect type') }; try { diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..771e374c82 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -56,7 +56,7 @@ class Migrations extends Action protected ?Device $deviceForFiles; protected ?Document $project; - protected Document $sourceProject; + protected ?Document $sourceProject = null; /** * @var callable @@ -74,7 +74,6 @@ class Migrations extends Action */ protected array $sourceReport = []; - private string $source; /** * @var callable|null */ @@ -130,7 +129,7 @@ class Migrations extends Action array $plan, Authorization $authorization, ): void { - $migrationMessage = Migration::fromArray($message->getPayload() ?? []); + $migrationMessage = Migration::fromArray($message->getPayload()); $this->getDatabasesDB = $getDatabasesDB; $this->getProjectDB = $getProjectDB; diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index db214f5d32..2706d33e2a 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -68,7 +68,7 @@ class StatsResources extends Action { $this->logError = $logError; - $statsResources = StatsResourcesMessage::fromArray($message->getPayload() ?? []); + $statsResources = StatsResourcesMessage::fromArray($message->getPayload()); if ($statsResources->project->isEmpty()) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 144c429629..dad444b381 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -151,7 +151,7 @@ class StatsUsage extends Action { $this->getLogsDB = $getLogsDB; $this->register = $register; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 509f0a6313..5b0497dbea 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -57,7 +57,7 @@ class Webhooks extends Action public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void { $this->errors = []; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..4b9ee63205 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -111,35 +111,23 @@ abstract class Format foreach (self::OAUTH_PROVIDER_BLACKLIST as $config) { foreach ($config['methods'] as $method) { - $entry = [ + $blacklist[] = [ 'namespace' => $config['namespace'], 'method' => $method, 'parameter' => $config['parameter'], + 'excludeKeys' => $config['excludeKeys'], ]; - if (isset($config['excludeKeys'])) { - $entry['excludeKeys'] = $config['excludeKeys']; - } - if (isset($config['exclude'])) { - $entry['exclude'] = $config['exclude']; - } - $blacklist[] = $entry; } } foreach (self::PROVIDER_USAGE_BLACKLIST as $config) { foreach ($config['methods'] as $method) { - $entry = [ + $blacklist[] = [ 'namespace' => $config['namespace'], 'method' => $method, 'parameter' => $config['parameter'], + 'exclude' => $config['exclude'], ]; - if (isset($config['excludeKeys'])) { - $entry['excludeKeys'] = $config['excludeKeys']; - } - if (isset($config['exclude'])) { - $entry['exclude'] = $config['exclude']; - } - $blacklist[] = $entry; } } @@ -968,8 +956,7 @@ abstract class Format continue; } - $config['required'] = $override['required'] ?? $config['required']; - $config['nullable'] = $override['nullable'] ?? $config['nullable']; + $config['required'] = $override['required']; break; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index fcff6ac2f4..bcb5a5486c 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -114,16 +114,16 @@ class OpenAPI3 extends Format */ $consumes = [$sdk->getRequestType()->value]; - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -185,7 +185,7 @@ class OpenAPI3 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -291,7 +291,7 @@ class OpenAPI3 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -312,7 +312,7 @@ class OpenAPI3 extends Format $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'content' => [ $produces => [ @@ -326,7 +326,7 @@ class OpenAPI3 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'content' => [ $produces => [ @@ -339,9 +339,9 @@ class OpenAPI3 extends Format } } - if (($response->getCode() ?? 500) === 204) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['content']); + if ($response->getCode() === 204) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['content']); } } @@ -385,7 +385,7 @@ class OpenAPI3 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -404,13 +404,9 @@ class OpenAPI3 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -469,6 +465,7 @@ class OpenAPI3 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -619,7 +616,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -635,7 +632,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['items']['enum'] = $enumValues; - $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['items']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -643,7 +640,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['items']['format'] = $validator->getFormat(); } } else { $node['schema']['type'] = $validator->getType(); @@ -673,7 +670,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -689,7 +686,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['enum'] = $enumValues; - $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -697,7 +694,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['format'] = $validator->getFormat(); } } break; @@ -781,18 +778,10 @@ class OpenAPI3 extends Format $body['content'][$consumes[0]]['schema']['properties'][$name]['x-upload-id'] = $node['schema']['x-upload-id']; } - if (isset($node['default'])) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['default'] = $node['default']; - } - if (\array_key_exists('items', $node['schema'])) { $body['content'][$consumes[0]]['schema']['properties'][$name]['items'] = $node['schema']['items']; } - if ($node['x-global'] ?? false) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; - } - if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 8d47766117..d07d957577 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -114,17 +114,17 @@ class Swagger2 extends Format $consumes = [$sdk->getRequestType()->value]; } - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); $sdkPlatforms = array_values(array_unique($sdkPlatforms)); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -193,7 +193,7 @@ class Swagger2 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -298,7 +298,7 @@ class Swagger2 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -320,7 +320,7 @@ class Swagger2 extends Format foreach ($model as $m) { $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { @@ -332,7 +332,7 @@ class Swagger2 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'schema' => [ '$ref' => '#/definitions/' . $model->getType(), @@ -341,9 +341,9 @@ class Swagger2 extends Format } } - if (in_array($response->getCode() ?? 500, [204, 301, 302, 308], true)) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['schema']); + if (in_array($response->getCode(), [204, 301, 302, 308], true)) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['schema']); } } @@ -387,7 +387,7 @@ class Swagger2 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -406,13 +406,9 @@ class Swagger2 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -471,6 +467,7 @@ class Swagger2 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -624,7 +621,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['items']['format'] = $validator->getFormat(); } } else { $node['type'] = $validator->getType(); @@ -672,7 +669,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['format'] = $validator->getFormat() ?? 'int32'; + $node['format'] = $validator->getFormat(); } } break; @@ -758,11 +755,7 @@ class Swagger2 extends Format /// If the enum flag is Set, add the enum values to the body $body['schema']['properties'][$name]['enum'] = $node['enum']; $body['schema']['properties'][$name]['x-enum-name'] = $node['x-enum-name'] ?? null; - $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys'] ?? null; - } - - if ($node['x-global'] ?? false) { - $body['schema']['properties'][$name]['x-global'] = true; + $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys']; } if ($parameter['nullable']) { diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index f8bdd01103..16bf0909d2 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -188,13 +188,13 @@ class Attributes extends Validator } // Validate required and default conflict - if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when required is true"; return false; } // Validate array and default conflict - if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when array is true"; return false; } @@ -331,7 +331,7 @@ class Attributes extends Validator } // Validate default exists in elements - if (isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['default'])) { if (!in_array($attribute['default'], $attribute['elements'], true)) { $this->message = "Default value for enum attribute '" . $attribute['key'] . "' must be one of the provided elements"; return false; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index 07e27f06cb..9fbc158ab2 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -51,17 +51,15 @@ class Webhooks extends Base */ public function isValid($value): bool { - if (\is_array($value)) { - foreach ($value as &$queryString) { - if (!\is_string($queryString)) { - continue; - } - foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { - $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); - } + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); } - unset($queryString); } + unset($queryString); return parent::isValid($value); } diff --git a/src/Appwrite/Utopia/Fetch/BodyMultipart.php b/src/Appwrite/Utopia/Fetch/BodyMultipart.php index ee482a7d9e..90732eb7a1 100644 --- a/src/Appwrite/Utopia/Fetch/BodyMultipart.php +++ b/src/Appwrite/Utopia/Fetch/BodyMultipart.php @@ -64,7 +64,7 @@ class BodyMultipart $partHeaderArray = \explode(':', $partHeader, 2); - $partHeaderName = \strtolower($partHeaderArray[0] ?? ''); + $partHeaderName = \strtolower($partHeaderArray[0]); $partHeaderValue = $partHeaderArray[1] ?? ''; if ($partHeaderName == "content-disposition") { $dispositionChunks = \explode("; ", $partHeaderValue); @@ -92,7 +92,7 @@ class BodyMultipart */ public function getParts(): array { - return $this->parts ?? []; + return $this->parts; } public function getPart(string $key, mixed $default = ''): mixed diff --git a/src/Appwrite/Utopia/Request/Filter.php b/src/Appwrite/Utopia/Request/Filter.php index 4bd9b394a0..638d6f993a 100644 --- a/src/Appwrite/Utopia/Request/Filter.php +++ b/src/Appwrite/Utopia/Request/Filter.php @@ -45,12 +45,6 @@ abstract class Filter */ public function getParamValue(string $key, mixed $default = ''): mixed { - try { - $value = $this->params[$key] ?? $default; - } catch (\Exception $e) { - $value = $default; - } - - return $value; + return $this->params[$key] ?? $default; } } diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index e3d5fe2f79..a290656b6e 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -58,7 +58,7 @@ class V20 extends Filter throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $selections = Query::groupByType($parsed)['selections'] ?? []; + $selections = Query::groupByType($parsed)['selections']; // Check if we need to add wildcard + relationships // This happens when: diff --git a/src/Appwrite/Utopia/Response/Filters/V16.php b/src/Appwrite/Utopia/Response/Filters/V16.php index 7eb3ec6eb3..74bae97abb 100644 --- a/src/Appwrite/Utopia/Response/Filters/V16.php +++ b/src/Appwrite/Utopia/Response/Filters/V16.php @@ -40,7 +40,7 @@ class V16 extends Filter } if (isset($content['buildSize'])) { - $content['size'] += + $content['buildSize'] ?? 0; + $content['size'] += +$content['buildSize']; unset($content['buildSize']); } diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 148b29c1d1..6214bb1f29 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -148,6 +148,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($site['action']['type'] === 'logs') { @@ -195,6 +196,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($function['action']['type'] === 'logs') { @@ -245,7 +247,7 @@ class Comment public function parseComment(string $comment): self { - $state = \explode("\n", $comment)[0] ?? ''; + $state = \explode("\n", $comment)[0]; $state = substr($state, strlen($this->statePrefix)); $json = \base64_decode($state); diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index f899f06bad..a4f1ae44cd 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -297,10 +297,10 @@ class Executor * @param array $params * @param array $headers * @param bool $decode - * @return array|string + * @return array * @throws Exception */ - private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null) + private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null): array { $headers = array_merge($this->headers, $headers); $ch = curl_init($endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); @@ -392,7 +392,7 @@ class Executor $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index d170d56fe4..4358058fe3 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -264,7 +264,7 @@ class Client $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index f6eb963967..4f557e8959 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1605,8 +1605,6 @@ class UsageTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeploymentSite($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static'), diff --git a/tests/e2e/Scopes/ApiVectorsDB.php b/tests/e2e/Scopes/ApiVectorsDB.php deleted file mode 100644 index 09494d3c10..0000000000 --- a/tests/e2e/Scopes/ApiVectorsDB.php +++ /dev/null @@ -1,110 +0,0 @@ -assertNotEmpty($code); $this->assertStringContainsStringIgnoringCase('Use OTP ' . $code . ' to sign in to '. $this->getProject()['name'] . '. Expires in 15 minutes.', $lastEmail['text']); diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 9f825c3c89..cd2c43381c 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -203,7 +203,7 @@ class AccountConsoleClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 49f0c4c245..888611f4ea 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2135,7 +2135,7 @@ class AccountCustomClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); @@ -3363,7 +3363,7 @@ class AccountCustomClientTest extends Scope { $data = $this->setupPhoneAccount(); $id = $data['id']; - $token = explode(" ", $data['token'])[0] ?? ''; + $token = explode(" ", $data['token'])[0]; $number = $data['number']; /** diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index f5f1d1864c..236bf79d87 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -936,7 +936,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } // Use dedicated collections for this test to avoid conflicts with setupAttributes() $data = $this->setupDatabase(); @@ -1189,7 +1188,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -1221,7 +1219,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1290,7 +1287,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), [ 'content-type' => 'application/json', @@ -1351,7 +1347,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -3324,7 +3319,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3458,7 +3452,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3531,7 +3524,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3578,7 +3570,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -4929,7 +4920,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), array_merge([ 'content-type' => 'application/json', diff --git a/tests/e2e/Services/Databases/Transactions/ACIDBase.php b/tests/e2e/Services/Databases/Transactions/ACIDBase.php index 1a6ee83b33..11b6de3b70 100644 --- a/tests/e2e/Services/Databases/Transactions/ACIDBase.php +++ b/tests/e2e/Services/Databases/Transactions/ACIDBase.php @@ -178,7 +178,6 @@ trait ACIDBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('This adapter does not support attributes; schema constraint consistency cannot be tested.'); - return; } // Create database diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php index 8dc2fe337f..ed436ad075 100644 --- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php @@ -184,7 +184,7 @@ class FunctionsClientTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -194,7 +194,7 @@ class FunctionsClientTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -204,7 +204,7 @@ class FunctionsClientTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index 8e1c7ac7e7..572fde49bf 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -186,7 +186,7 @@ class FunctionsServerTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -196,7 +196,7 @@ class FunctionsServerTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -206,7 +206,7 @@ class FunctionsServerTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php index 4a3e49cc60..d3c6d01ffa 100644 --- a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php +++ b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index 25041e843b..dd89819c34 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -112,7 +112,7 @@ class StorageClientTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } /** diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index cc4c8ecec3..1377ef9207 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -111,7 +111,7 @@ class StorageServerTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } public function testGetBuckets(): array diff --git a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php index 9c6910fb30..13f083f0eb 100644 --- a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php +++ b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index ff6e8e3c6f..dd546119e2 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -199,7 +199,7 @@ class TeamsServerTest extends Scope public function testUpdateTeamPrefs() { $team = $this->setupTeamWithPrefs(); - $this->assertIsArray($team); + $this->assertNotEmpty($team); } public function testGetTeamPreferences() diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..967dd16fb3 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6,7 +6,6 @@ use Appwrite\Extend\Exception; use Appwrite\Tests\Async; use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; -use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; @@ -831,49 +830,6 @@ class ProjectsConsoleClientTest extends Scope $this->markTestIncomplete( 'This test is failing right now due to functions collection.' ); - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/project/usage', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'startDate' => UsageTest::getToday(), - 'endDate' => UsageTest::getTomorrow(), - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(8, count($response['body'])); - $this->assertNotEmpty($response['body']); - $this->assertIsArray($response['body']['requests']); - $this->assertIsArray($response['body']['network']); - $this->assertIsNumeric($response['body']['executionsTotal']); - $this->assertIsNumeric($response['body']['rowsTotal']); - $this->assertIsNumeric($response['body']['databasesTotal']); - $this->assertIsNumeric($response['body']['bucketsTotal']); - $this->assertIsNumeric($response['body']['usersTotal']); - $this->assertIsNumeric($response['body']['filesStorageTotal']); - $this->assertIsNumeric($response['body']['deploymentStorageTotal']); - $this->assertIsNumeric($response['body']['authPhoneTotal']); - $this->assertIsNumeric($response['body']['authPhoneEstimate']); - - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/projects/empty', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(404, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/id-is-really-long-id-is-really-long-id-is-really-long-id-is-really-long', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(400, $response['headers']['status-code']); } public function testUpdateProject(): void diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index edce428e0f..102f41933b 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -513,7 +513,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client->receive(); $this->fail('Expected TimeoutException - event should be filtered by updated query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ca07d45f46..ef1c5fce7a 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3828,7 +3828,7 @@ class RealtimeCustomClientTest extends Scope $this->fail('Should not receive any event after rollback'); } catch (TimeoutException $e) { // Expected - no event should be triggered - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -5655,7 +5655,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Test Document Decrement @@ -5686,7 +5686,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04ed56dae6..04b8400b57 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -101,7 +101,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -206,7 +206,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -304,7 +304,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -398,7 +398,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -492,7 +492,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -604,7 +604,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -716,7 +716,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -810,7 +810,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -903,7 +903,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1019,7 +1019,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with priority > 5 but status != 'active' - should NOT receive event @@ -1041,7 +1041,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1157,7 +1157,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1296,7 +1296,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event @@ -1318,7 +1318,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1511,7 +1511,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1583,7 +1583,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1692,7 +1692,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with matching ID but wrong status - should NOT receive event (only one query matches) @@ -1713,7 +1713,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1870,7 +1870,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientComplex: should receive event, subscriptions should not be empty (query matched) @@ -1912,7 +1912,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientQ2: should receive event, subscriptions should not be empty (query matched) @@ -1929,7 +1929,7 @@ trait RealtimeQueryBase $clientComplex->receive(); $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientAll->close(); @@ -2043,7 +2043,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // 2) pending document -> only queryStatusPending subscription should see it @@ -2073,7 +2073,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientQ1->close(); @@ -2252,7 +2252,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - document does not match query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a NEW document with a different ID - should NOT receive event @@ -2279,7 +2279,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - new document does not match original query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a document with the ORIGINAL matching ID - should receive event @@ -2439,7 +2439,7 @@ trait RealtimeQueryBase $clientWithNonMatchingQuery->receive(); $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientNoQuery->close(); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 69dbd7fdf0..59727b8d22 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -801,8 +801,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -881,8 +879,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static-single-file'), @@ -943,8 +939,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -995,8 +989,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1040,8 +1032,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1243,8 +1233,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1294,8 +1282,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -1383,8 +1369,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1427,8 +1411,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $site = $this->deleteSite($siteId); $this->assertEquals(204, $site['headers']['status-code']); diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index 601bf1d2d0..80e406eac9 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -147,7 +147,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); @@ -204,7 +203,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp field'); $expectedExp = (new \DateTime($expiry))->getTimestamp(); @@ -226,7 +224,6 @@ class TokensConsoleClientTest extends Scope // Verify JWT does not contain exp for infinite expiry using native JWT decode try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayNotHasKey('exp', $payload, 'JWT payload should not contain exp field for infinite expiry'); } catch (JWTException $e) { $this->fail('Failed to decode JWT: ' . $e->getMessage()); @@ -265,7 +262,6 @@ class TokensConsoleClientTest extends Scope // Verify the JWT token is valid and contains correct information try { $payload = $jwt->decode($token['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); diff --git a/tests/e2e/Traits/DatabaseFixture.php b/tests/e2e/Traits/DatabaseFixture.php deleted file mode 100644 index f3ba10e765..0000000000 --- a/tests/e2e/Traits/DatabaseFixture.php +++ /dev/null @@ -1,239 +0,0 @@ -ensureFixturesCreated(); - return self::$fixtureDatabaseId; - } - - protected function getFixtureMoviesId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureMoviesId; - } - - protected function getFixtureActorsId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureActorsId; - } - - protected function getFixtureDocumentIds(): array - { - $this->ensureFixturesCreated(); - return self::$fixtureDocumentIds; - } - - protected function ensureFixturesCreated(): void - { - if (self::$fixturesInitialized) { - return; - } - - $this->createDatabaseFixtures(); - self::$fixturesInitialized = true; - } - - protected function createDatabaseFixtures(): void - { - $config = $this->getSchemaApiConfig(); - $isTablesDB = $config['basePath'] === '/tablesdb'; - - // Create database - $database = $this->client->call(Client::METHOD_POST, $config['basePath'], [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'databaseId' => ID::unique(), - 'name' => 'Fixture Database' - ]); - - self::$fixtureDatabaseId = $database['body']['$id']; - $databaseId = self::$fixtureDatabaseId; - - $collectionEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath']; - $collectionKey = $isTablesDB ? 'tableId' : 'collectionId'; - $docKey = $isTablesDB ? 'rowId' : 'documentId'; - $docEndpoint = $isTablesDB ? 'rows' : 'documents'; - - // Create Movies collection - $movies = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Movies', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureMoviesId = $movies['body']['$id']; - - // Create Actors collection - $actors = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Actors', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureActorsId = $actors['body']['$id']; - - // Create attributes on Movies - $attrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'description', - 'size' => 512, - 'required' => false, - 'default' => '', - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/integer', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'releaseYear', - 'required' => false, - 'default' => 0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/float', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'rating', - 'required' => false, - 'default' => 0.0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/boolean', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'active', - 'required' => false, - 'default' => true, - ]); - - // Create attributes on Actors - $actorAttrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureActorsId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $actorAttrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - - $this->waitForAllAttributes($databaseId, self::$fixtureMoviesId); - $this->waitForAllAttributes($databaseId, self::$fixtureActorsId); - - // Create indexes - $indexEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['indexPath']; - - $this->client->call(Client::METHOD_POST, $indexEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title_index', - 'type' => 'key', - 'attributes' => ['title'], - ]); - - $this->waitForAllIndexes($databaseId, self::$fixtureMoviesId); - - // Create sample documents - $docsEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $docEndpoint; - - $sampleMovies = [ - ['title' => 'Inception', 'description' => 'A mind-bending thriller', 'releaseYear' => 2010, 'rating' => 8.8, 'active' => true], - ['title' => 'The Matrix', 'description' => 'A sci-fi classic', 'releaseYear' => 1999, 'rating' => 8.7, 'active' => true], - ['title' => 'Interstellar', 'description' => 'Space exploration epic', 'releaseYear' => 2014, 'rating' => 8.6, 'active' => true], - ]; - - foreach ($sampleMovies as $movie) { - $doc = $this->client->call(Client::METHOD_POST, $docsEndpoint, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - $docKey => ID::unique(), - 'data' => $movie, - 'permissions' => [ - Permission::read(Role::users()), - Permission::update(Role::user($this->getUser()['$id'])), - Permission::delete(Role::user($this->getUser()['$id'])), - ], - ]); - - self::$fixtureDocumentIds[] = $doc['body']['$id']; - } - } - - public static function tearDownAfterClass(): void - { - self::$fixtureDatabaseId = null; - self::$fixtureMoviesId = null; - self::$fixtureActorsId = null; - self::$fixtureDocumentIds = []; - self::$fixturesInitialized = false; - - parent::tearDownAfterClass(); - } -} diff --git a/tests/extensions/Async/Eventually.php b/tests/extensions/Async/Eventually.php index 10f6b41eee..d8c9dc998d 100644 --- a/tests/extensions/Async/Eventually.php +++ b/tests/extensions/Async/Eventually.php @@ -11,7 +11,7 @@ final class Eventually extends Constraint { } - public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): ?bool + public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): bool { if (!is_callable($probe)) { throw new \Exception('Probe must be a callable'); diff --git a/tests/extensions/RetrySubscriber.php b/tests/extensions/RetrySubscriber.php index 08623dc261..ff09b187d4 100644 --- a/tests/extensions/RetrySubscriber.php +++ b/tests/extensions/RetrySubscriber.php @@ -16,13 +16,6 @@ class RetrySubscriber implements FailedSubscriber */ private static array $retryCounts = []; - /** - * Track tests that should be retried - * - * @var array - */ - private static array $pendingRetries = []; - public function notify(Failed $event): void { $this->handleTestFailure($event->test(), $event->throwable()->asString()); @@ -98,6 +91,5 @@ class RetrySubscriber implements FailedSubscriber public static function reset(): void { self::$retryCounts = []; - self::$pendingRetries = []; } } diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php index fc2d839ca6..af6592ef92 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -203,7 +203,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } @@ -240,7 +239,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index 6e4a78022f..845d01e723 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -33,10 +33,7 @@ class DNSTest extends TestCase $result = $validator->isValid('nonexistent-domain-' . \uniqid() . '.com'); $this->assertEquals(false, $result); - $this->assertIsInt($validator->count); - $this->assertIsString($validator->value); - $this->assertIsArray($validator->records); - $this->assertIsString($validator->getDescription()); + $this->assertNotEmpty($validator->getDescription()); } public function testCoreDNSFailure(): void diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index 507a4e25f6..87babcfb16 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -157,7 +157,7 @@ class ModuleTest extends TestCase $platform->init(Service::TYPE_HTTP); // If we get here without exceptions, route registration succeeded - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testModuleHasNoTaskServices(): void @@ -267,14 +267,6 @@ class ModuleTest extends TestCase } } - public function testValidateClassHasCsrfMethod(): void - { - $this->assertTrue( - method_exists(Validate::class, 'validateCsrf'), - 'Validate class should expose validateCsrf method' - ); - } - private function getAction(string $name): Action { $services = $this->module->getServicesByType(Service::TYPE_HTTP); diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php index 6c36e6d732..c8cfd6d884 100644 --- a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php +++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php @@ -19,14 +19,7 @@ class StateTest extends TestCase $this->tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid(); mkdir($this->tempDir, 0755, true); - $root = dirname(__DIR__, 6); - $this->state = new State([ - 'public' => $root . '/public', - 'init' => $root . '/app/init.php', - 'views' => $root . '/app/views/install', - 'vendor' => $root . '/vendor/autoload.php', - 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', - ]); + $this->state = new State(); // Preserve env state $env = getenv('APPWRITE_INSTALLER_CONFIG'); @@ -273,7 +266,6 @@ class StateTest extends TestCase public function testReadProgressFileReturnsDefaultForMissing(): void { $data = $this->state->readProgressFile('nonexistent-id-' . uniqid()); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -291,7 +283,6 @@ class StateTest extends TestCase ]); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('steps', $data); $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']); @@ -604,7 +595,6 @@ class StateTest extends TestCase file_put_contents($path, 'not valid json {{{'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -618,7 +608,6 @@ class StateTest extends TestCase file_put_contents($path, ''); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertEmpty($data['steps']); } @@ -631,7 +620,6 @@ class StateTest extends TestCase file_put_contents($path, '"just a string"'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertEmpty($data['steps']); } diff --git a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php index c453dcade4..0a360783ac 100644 --- a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php +++ b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php @@ -22,7 +22,6 @@ class AppDomainTest extends TestCase public function testDescription(): void { $this->assertNotEmpty($this->validator->getDescription()); - $this->assertIsString($this->validator->getDescription()); } public function testIsArray(): void diff --git a/tests/unit/URL/URLTest.php b/tests/unit/URL/URLTest.php index ceca1c6304..597d77f74c 100644 --- a/tests/unit/URL/URLTest.php +++ b/tests/unit/URL/URLTest.php @@ -11,7 +11,6 @@ class URLTest extends TestCase { $url = URL::parse('https://appwrite.io:8080/path?query=string¶m=value'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals('8080', $url['port']); @@ -20,7 +19,6 @@ class URLTest extends TestCase $url = URL::parse('https://appwrite.io'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals(null, $url['port']); @@ -29,7 +27,6 @@ class URLTest extends TestCase $url = URL::parse('appwrite-callback-project://'); - $this->assertIsArray($url); $this->assertEquals('appwrite-callback-project', $url['scheme']); $this->assertEquals('', $url['host']); $this->assertEquals(null, $url['port']); @@ -47,7 +44,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io:8080/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -58,7 +54,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -69,7 +64,6 @@ class URLTest extends TestCase 'query' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/', $url); $url = URL::unparse([ @@ -80,7 +74,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -93,7 +86,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://eldad:fux@appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -106,7 +98,6 @@ class URLTest extends TestCase 'fragment' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#', $url); } @@ -114,7 +105,6 @@ class URLTest extends TestCase { $result = URL::parseQuery('param1=value1¶m2=value2'); - $this->assertIsArray($result); $this->assertEquals(['param1' => 'value1', 'param2' => 'value2'], $result); } @@ -122,7 +112,6 @@ class URLTest extends TestCase { $result = URL::unparseQuery(['param1' => 'value1', 'param2' => 'value2']); - $this->assertIsString($result); $this->assertEquals('param1=value1¶m2=value2', $result); } } diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index f7d73eb287..d5507327be 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -659,7 +659,7 @@ class RuntimeQueryTest extends TestCase $query = Query::select(['*']); // Should not throw RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testValidateSelectQueryWithSpecificFields(): void @@ -694,7 +694,7 @@ class RuntimeQueryTest extends TestCase $query = Query::equal('name', ['John']); // Should not throw for non-select queries RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Filter tests with select("*") diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index d5cd5d800a..81e0ead4b3 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -23,7 +23,6 @@ class RequestTest extends TestCase public function testFilters(): void { $this->assertFalse($this->request->hasFilters()); - $this->assertIsArray($this->request->getFilters()); $this->assertEmpty($this->request->getFilters()); $this->request->addFilter(new First()); diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index be8cfdc216..f5a30a5500 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -26,7 +26,6 @@ class ResponseTest extends TestCase public function testFilters(): void { $this->assertFalse($this->response->hasFilters()); - $this->assertIsArray($this->response->getFilters()); $this->assertEmpty($this->response->getFilters()); $this->response->addFilter(new First()); From d86258a6f6fe308bf439692b899f179185666b8f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 19 Apr 2026 17:52:51 +0530 Subject: [PATCH 036/123] fix: restore runtime guards and widen types missed by PHPStan cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from CI that the level-4 pass got wrong: 1. `account.php` / `users.php`: `Document::find()` returns `mixed` (specifically `Document|false` in practice), not `Document`. The earlier `@var Document $oldTarget` docblocks were lies, and the runtime `instanceof Document` guards were load-bearing — removing them caused `Call to a member function isEmpty() on false` 500s on the `PATCH /v1/users/:id/email` and `/phone` endpoints (and the analogous `/v1/account/email`, `/v1/account/phone` flows). Dropped the misleading `@var` docblocks and restored `$oldTarget instanceof Document && !$oldTarget->isEmpty()`. 2. `Installer/Runtime/Config::setEnabledDatabases()` is a boundary that actually takes arbitrary user/compose input — not a trusted `string[]`. The `is_string($v)` filter was covering for that, and `ConfigTest::testSetEnabledDatabasesFiltersInvalid` explicitly asserts it. Widened the PHPDoc to `array` and restored `is_string($v) && $v !== ''` in the filter. 3. `OAuth2/Apple::getAppSecret()` wrapped `json_decode` in a `try/catch (\Throwable)` — but `json_decode` without `JSON_THROW_ON_ERROR` returns `null` on failure, it doesn't throw. PHP 8.3's PHPStan flagged the catch as dead (PHP 8.5 didn't, which is why it slipped through locally). Replaced with `if (!\is_array($secret)) throw`, which preserves the original "invalid secret" guard. --- app/controllers/api/account.php | 10 ++-------- app/controllers/api/users.php | 10 ++-------- src/Appwrite/Auth/OAuth2/Apple.php | 6 +++--- src/Appwrite/Platform/Installer/Runtime/Config.php | 4 ++-- 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 4b2d7a31b8..daabe9e3a3 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3434,12 +3434,9 @@ Http::patch('/v1/account/email') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $email))); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -3523,12 +3520,9 @@ Http::patch('/v1/account/phone') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $phone))); } $dbForProject->purgeCachedDocument('users', $user->getId()); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 57c5854422..342ca0648f 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1595,12 +1595,9 @@ Http::patch('/v1/users/:userId/email') 'emailIsDisposable' => $user->getAttribute('emailIsDisposable'), 'emailIsFree' => $user->getAttribute('emailIsFree'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { if (\strlen($email) !== 0) { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $email])); $oldTarget->setAttribute('identifier', $email); @@ -1691,12 +1688,9 @@ Http::patch('/v1/users/:userId/phone') 'phone' => $phoneValue, 'phoneVerification' => $user->getAttribute('phoneVerification'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { if ($number !== '') { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number])); $oldTarget->setAttribute('identifier', $number); diff --git a/src/Appwrite/Auth/OAuth2/Apple.php b/src/Appwrite/Auth/OAuth2/Apple.php index 0b4ec50881..bae3446fcb 100644 --- a/src/Appwrite/Auth/OAuth2/Apple.php +++ b/src/Appwrite/Auth/OAuth2/Apple.php @@ -165,9 +165,9 @@ class Apple extends OAuth2 protected function getAppSecret(): string { - try { - $secret = \json_decode($this->appSecret, true); - } catch (\Throwable $th) { + $secret = \json_decode($this->appSecret, true); + + if (!\is_array($secret)) { throw new Exception('Invalid secret'); } diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php index 978407894e..6142e47152 100644 --- a/src/Appwrite/Platform/Installer/Runtime/Config.php +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -218,11 +218,11 @@ final class Config } /** - * @param string[] $value + * @param array $value */ public function setEnabledDatabases(array $value): void { - $filtered = array_values(array_filter($value, fn ($v) => $v !== '')); + $filtered = array_values(array_filter($value, fn ($v) => is_string($v) && $v !== '')); if (!empty($filtered)) { $this->enabledDatabases = $filtered; } From adb4e4ef360282bd7f8af06ccbf3c84f442b23d6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 19 Apr 2026 20:34:51 +0530 Subject: [PATCH 037/123] ci: fix benchmark by pulling compose from GitHub raw for the latest tag `https://appwrite.io/install/compose` now returns a 308 redirect to the HTML install docs (`/docs/advanced/self-hosting/installation`) instead of serving the compose file, so the Benchmark job's "Installing latest version" step was downloading 0 bytes and `docker compose up -d` died with "empty compose file". This has been failing the Benchmark job on every recent PR, not just this one. Resolve the latest release tag via the GitHub API, then fetch the compose file and `.env` from `raw.githubusercontent.com` at that tag. Switched both curl calls to `-fsSL` so they fail loudly on non-2xx responses or redirect loss instead of silently writing empty files. --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8256ddc7a..50acb7230f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -703,8 +703,10 @@ jobs: run: | rm docker-compose.yml rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml - curl https://appwrite.io/install/env -o .env + LATEST_TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/appwrite/appwrite/releases/latest | jq -r .tag_name) + echo "Latest release tag: $LATEST_TAG" + curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/docker-compose.yml" -o docker-compose.yml + curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/.env" -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d sleep 10 From 6b66923f1861d2f93e3f2802f07f653d44f593d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 19:36:24 +0200 Subject: [PATCH 038/123] Fix delete response placeholder audit label --- app/controllers/api/account.php | 4 ++-- app/controllers/api/users.php | 4 ++-- .../Account/Http/Account/MFA/Authenticators/Delete.php | 4 ++-- .../Modules/Project/Http/Project/Platforms/Delete.php | 2 +- .../Modules/Project/Http/Project/Templates/Email/Delete.php | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..b26f6bc5c6 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -453,7 +453,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', @@ -4618,7 +4618,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/users.php b/app/controllers/api/users.php index a8875fc442..c922b2a1f6 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -2252,8 +2252,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/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/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/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index cf02704d47..176e8d7d63 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -39,7 +39,7 @@ class Delete extends Action ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].delete') ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{response.templateId}') + ->label('audits.resource', 'project.template/{request.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', From 37a2b1cbd9a9dbc1b0bbc6ea45bab45063e60208 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 20 Apr 2026 08:54:31 +0530 Subject: [PATCH 039/123] fix: restore executions limit cleanup behind a runtime env flag Per review feedback on the PHPStan cleanup, the two `if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE)` blocks in `app/controllers/general.php` and `src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php` were load-bearing feature flags, not dead code. Removing them silently dropped the ability to turn the cleanup on later. Changes: - Convert `ENABLE_EXECUTIONS_LIMIT_ON_ROUTE` from `const ... = false;` to a `define()` backed by the new `_APP_EXECUTIONS_LIMIT_ON_ROUTE` env var (defaults to `disabled`). PHPStan can no longer fold the `&&` away since the value is now runtime-resolved, so the guarded blocks are live again. - Restore the `/* cleanup */` block in the `router()` helper in `app/controllers/general.php`. - Restore the two cleanup blocks in `Functions/Http/Executions/Create.php` (one on the async-scheduled return path, one on the sync-response path), and re-add the `DeleteEvent $queueForDeletes` / `int $executionsRetentionCount` injections plus the `Appwrite\Event\Delete` import. Runtime behavior is identical to main (flag off by default); operators can now flip it via env without a code change. --- app/controllers/general.php | 14 +++++++++++ app/init/constants.php | 3 ++- .../Functions/Http/Executions/Create.php | 23 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 06ed676a76..a17f0dff04 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -771,6 +771,20 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S deployment: $deployment->getArrayCopy(), )); + /* cleanup */ + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { + $resourceType = $type === 'function' + ? RESOURCE_TYPE_FUNCTIONS + : RESOURCE_TYPE_SITES; + + $queueForDeletes + ->setProject($project) + ->setResourceType($resourceType) + ->setResource($resource->getSequence()) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + return true; } elseif ($type === 'api') { return false; diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..fa090a648c 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -1,6 +1,7 @@ inject('executor') ->inject('platform') ->inject('authorization') + ->inject('queueForDeletes') + ->inject('executionsRetentionCount') ->callback($this->action(...)); } @@ -126,6 +129,8 @@ class Create extends Base Executor $executor, array $platform, Authorization $authorization, + DeleteEvent $queueForDeletes, + int $executionsRetentionCount, ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -331,6 +336,15 @@ class Create extends Base $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { + $queueForDeletes + ->setProject($project) + ->setResource($function->getSequence()) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($execution, Response::MODEL_EXECUTION); return; @@ -509,6 +523,15 @@ class Create extends Base } } + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { + $queueForDeletes + ->setProject($project) + ->setResource($function->getSequence()) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); From a5c0a920baac5cbb6d2dc42d84fb482defade718 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 20 Apr 2026 04:01:30 +0000 Subject: [PATCH 040/123] feat: add afterQuery hook to list-documents/rows action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap each database call (find, count, transaction list/count) with a measuring closure so the actual DB duration is known — cache hits report near-zero, cache misses report only the DB time, not cache save / response serialization. After the response is sent, invoke a protected afterQuery() hook with the measured duration, the database/collection documents, and both parsed + raw query arrays. CE impl is a no-op; downstreams (e.g., cloud) can override it to log slow queries without relying on HTTP shutdown hooks or route-path matching. Exceptions from afterQuery are swallowed so observability never breaks the response. --- .../Http/TablesDB/Tables/Rows/XList.php | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 91c62aea05..315e1c2e67 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -2,15 +2,27 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows; +use Appwrite\Databases\TransactionState; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\XList as DocumentXList; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Exception\Order as OrderException; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Timeout; +use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -65,6 +77,200 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } + + /** + * List rows with actual database duration measurement and a post-query + * observability hook. Mirrors the parent listDocuments action body but + * wraps each DB call with a timer so subclasses can observe just the DB + * portion of the request via afterQuery(). + * + * @param array $queries + */ + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void + { + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $dbForDatabases = $getDatabasesDB($database); + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $documentId = $cursor->getValue(); + + $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); + + if ($cursorDocument->isEmpty()) { + $type = ucfirst($this->getContext()); + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $dbDurationMs = 0.0; + $measure = function (callable $fn) use (&$dbDurationMs) { + $start = \microtime(true); + try { + return $fn(); + } finally { + $dbDurationMs += (\microtime(true) - $start) * 1000; + } + }; + + try { + $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); + $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + $find = $hasSelects + ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) + : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); + + if ($transactionId !== null) { + $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); + $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; + } elseif ((int)$ttl > 0) { + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); + $roles = $dbForProject->getAuthorization()->getRoles(); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); + + $documentsCacheHit = false; + try { + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + } catch (\Throwable) { + $cachedDocuments = null; + } + + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; + } else { + $documents = $find(); + + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + try { + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + } catch (\Throwable) { + } + } + + if ($includeTotal) { + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + try { + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + } catch (\Throwable) { + $cachedTotal = null; + } + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = $cachedTotal; + } else { + $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); + try { + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } catch (\Throwable) { + } + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + } else { + $documents = $find(); + $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; + } + } catch (OrderException $e) { + $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; + $attribute = $this->isCollectionsAPI() ? 'attribute' : 'column'; + $message = "The order $attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all $documents order $attribute values are non-null."; + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } catch (Timeout) { + throw new Exception(Exception::DATABASE_TIMEOUT); + } + + $operations = 0; + $collectionsCache = []; + foreach ($documents as $document) { + $this->processDocument( + database: $database, + collection: $collection, + document: $document, + dbForProject: $dbForProject, + collectionsCache: $collectionsCache, + authorization: $authorization, + operations: $operations + ); + } + + $usage + ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); + + $response->dynamic(new Document([ + 'total' => $total, + $this->getSDKGroup() => $documents, + ]), $this->getResponseModel()); + + try { + $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); + } catch (\Throwable) { + // Observers must never break the response. + } + } + + /** + * Hook invoked after listRows completes the response. Under Swoole (the + * default transport) the client connection has already been closed by + * `$response->dynamic()`, so observers here do not delay the client. + * Under synchronous transports observers would run before the bytes + * reach the client — keep work here cheap regardless. + * + * Runs with the actual measured database duration (cache hits report + * near-zero). Intended to be overridden for observability (e.g., slow- + * query logging in downstream distributions). CE implementation is a + * no-op. + * + * The `$utopia` Http instance is passed so overrides can resolve + * additional resources (e.g., a downstream-specific logger) via + * `$utopia->getResource(...)` without needing to inject them here. + * + * @param array $queries parsed Query objects (pass directly to + * `Query::fingerprint()` if you need a + * shape hash) + */ + protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void + { + // no-op in CE + } } From a9fb17b0676be4cb2007dc8a5e2fdc03205c8eae Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 11:39:50 +0530 Subject: [PATCH 041/123] added telemetry for message arriaval delay --- app/realtime.php | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 5631a7f860..4f1a81b33a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -394,6 +394,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::success('Worker ' . $workerId . ' started successfully'); $telemetry = getTelemetry($workerId); + $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; $register->set('telemetry', fn () => $telemetry); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); @@ -401,7 +402,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( name: 'realtime.server.delivery_delay', unit: 'ms', - advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], + )); + $register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.arrival_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], )); $attempts = 0; @@ -519,6 +525,21 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) { $event = json_decode($payload, true); + $eventTimestamp = $event['data']['timestamp'] ?? null; + if (\is_string($eventTimestamp)) { + try { + $eventDate = new \DateTimeImmutable($eventTimestamp); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $eventTimestampMs = (float) $eventDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs); + + $register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } + if ($event['permissionsChanged'] && isset($event['userId'])) { $projectId = $event['project']; $userId = $event['userId']; From 2bff4192ee159365b6ede9fe5cc1f0b65542e0ac Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 11:45:05 +0530 Subject: [PATCH 042/123] added telemetry for the connected clients --- app/realtime.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 4f1a81b33a..96fb46b0b1 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -396,6 +396,8 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; $register->set('telemetry', fn () => $telemetry); + $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers')); + $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients')); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); @@ -409,6 +411,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, unit: 'ms', advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], )); + $register->get('telemetry.workerCounter')->add(1); $attempts = 0; $start = time(); @@ -661,6 +664,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); +$server->onWorkerStop(function (int $workerId) use ($register) { + Console::warning('Worker ' . $workerId . ' stopping'); + + try { + $register->get('telemetry.workerCounter')->add(-1); + } catch (\Throwable $th) { + Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage()); + } +}); + $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) { global $container; $request = new Request($request); @@ -749,6 +762,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); + $register->get('telemetry.workerClientCounter')->add(1); $register->get('telemetry.connectionCreatedCounter')->add(1); $stats->set($projectId, [ @@ -1161,6 +1175,7 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); + $register->get('telemetry.workerClientCounter')->add(-1); $projectId = $realtime->connections[$connection]['projectId']; From 12f76d74b16876c05db57bbfb7fec9663f98db57 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 11:52:43 +0530 Subject: [PATCH 043/123] added subscriptions telemetry and worker labelling to connections and subscriptions --- app/realtime.php | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 96fb46b0b1..591294dfa9 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -395,9 +395,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; + $workerTelemetryAttributes = ['workerId' => (string) $workerId]; $register->set('telemetry', fn () => $telemetry); + $register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes); $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers')); $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients')); + $register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions')); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); @@ -549,6 +552,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) { $connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId])); + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $consoleDatabase = getConsoleDB(); $project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId)); $database = getProjectDB($project); @@ -579,6 +583,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($authorization !== null) { $realtime->connections[$connection]['authorization'] = $authorization; } + + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } } } @@ -762,7 +772,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); - $register->get('telemetry.workerClientCounter')->add(1); + $register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes')); $register->get('telemetry.connectionCreatedCounter')->add(1); $stats->set($projectId, [ @@ -794,6 +804,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); + $register->get('telemetry.workerSubscriptionCounter')->add(1, $register->get('telemetry.workerAttributes')); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); @@ -827,6 +838,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping[$index] = $subscriptionId; } + if (!empty($subscriptions)) { + $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes')); + } $realtime->connections[$connection]['authorization'] = $authorization; @@ -881,7 +895,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } }); -$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { +$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) { $project = null; $authorization = null; try { @@ -995,6 +1009,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); $realtime->unsubscribe($connection); @@ -1019,6 +1034,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->connections[$connection]['authorization'] = $authorization; } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + $user = $response->output($user, Response::MODEL_ACCOUNT); $authResponsePayloadJson = json_encode([ @@ -1063,6 +1084,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // bulk validation + parsing before subscribing $parsedPayloads = []; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); foreach ($message['data'] as $payload) { if (!\is_array($payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.'); @@ -1107,6 +1129,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context. $realtime->connections[$connection]['authorization'] = $authorization; + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } $responsePayload = json_encode([ 'type' => 'response', @@ -1175,7 +1202,11 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); - $register->get('telemetry.workerClientCounter')->add(-1); + $register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes')); + $subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection)); + if ($subscriptionsBeforeClose > 0) { + $register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes')); + } $projectId = $realtime->connections[$connection]['projectId']; From 4b94d14f1eb9ad1b8b16dc8109830ccf33fcde45 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 12:13:53 +0530 Subject: [PATCH 044/123] updated time metric --- app/realtime.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 591294dfa9..4621c6543e 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -534,7 +534,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $eventTimestamp = $event['data']['timestamp'] ?? null; if (\is_string($eventTimestamp)) { try { - $eventDate = new \DateTimeImmutable($eventTimestamp); + $eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC')); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $eventTimestampMs = (float) $eventDate->format('U.u') * 1000; $nowTimestampMs = (float) $now->format('U.u') * 1000; @@ -634,7 +634,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; if (\is_string($updatedAt)) { try { - $updatedAtDate = new \DateTimeImmutable($updatedAt); + $updatedAtDate = new \DateTimeImmutable($updatedAt, new \DateTimeZone('UTC')); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; $nowTimestampMs = (float) $now->format('U.u') * 1000; From eef443f07e0288ef710daba41fe525a8622b1635 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 20 Apr 2026 07:53:36 +0000 Subject: [PATCH 045/123] chore: bump utopia-php/database to 5.3.22 for Query::fingerprint --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 56b838a0fe..9538b6e874 100644 --- a/composer.lock +++ b/composer.lock @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.21", + "version": "5.3.22", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d" + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/ee2d7d4c87b3a3fae954089ad7494ceb454f619d", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d", + "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.21" + "source": "https://github.com/utopia-php/database/tree/5.3.22" }, - "time": "2026-04-10T12:38:57+00:00" + "time": "2026-04-20T07:12:46+00:00" }, { "name": "utopia-php/detector", From c4d9c3dc4f984b1a2df06239bd4a17116a35caf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:28:21 +0200 Subject: [PATCH 046/123] Improve endpoint quality --- .../Http/Project/SMTP/Credentials/Update.php | 144 ---------------- .../Http/Project/SMTP/Status/Update.php | 74 --------- .../Project/Http/Project/SMTP/Update.php | 157 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 - .../Utopia/Response/Model/Project.php | 14 +- 5 files changed, 169 insertions(+), 222 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php deleted file mode 100644 index 9117554622..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php +++ /dev/null @@ -1,144 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp/credentials') - ->httpAlias('/v1/projects/:projectId/smtp') - ->desc('Update project SMTP details') - ->groups(['api', 'project']) - ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'smtp', - name: 'updateSMTPCredentials', - description: <<param('senderName', '', new Text(256), 'Name of the email sender') - ->param('senderEmail', '', 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') - ->param('username', '', new Text(256), 'SMTP server username', true) - ->param('password', '', new Text(256), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); - } - - - public function action( - string $senderName, - string $senderEmail, - string $replyTo, - string $host, - int $port, - string $username, - string $password, - string $secure, - ?bool $enabled, // Backwards compatibility - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - // Backwards compatibility - if (!\is_null($enabled) && $enabled === false) { - $smtp = $project->getAttribute('smtp', []); - - $smtp['enabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - - return; - } - - // Validate SMTP settings - $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()); - } - - $smtp = [ - 'enabled' => true, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php deleted file mode 100644 index 0669f6344f..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php +++ /dev/null @@ -1,74 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp/status') - ->desc('Update project SMTP status') - ->groups(['api', 'project']) - ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'smtp', - name: 'updateSMTPStatus', - description: <<param('enabled', null, new Boolean(), 'SMTP status.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); - } - - public function action( - bool $enabled, - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - $smtp = $project->getAttribute('smtp', []); - - $smtp['enabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - } -} 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..3c25750959 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,157 @@ +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', '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', '', new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', 587, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', '', new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', '', 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', '', new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', '', new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', '', new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', '', new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', '', 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}; + } + } + + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new \Exception('"' . $key . '" is required. Please provide a value.'); + } + } + + // Validate SMTP credentials + if($smtp['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.'); + } + } catch (Throwable $error) { + 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/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 4fea33220b..ac048c6022 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -25,7 +25,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatfo use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Credentials\Update as UpdateSMTPCredentials; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -50,7 +49,6 @@ class Http extends Service // SMTP $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); - $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Variables diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..e1909c4785 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -247,7 +247,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' => '', @@ -271,12 +277,15 @@ class Project extends Model 'default' => '', 'example' => 'emailuser', ]) + /* + We intentionally do not expose SMTP password - it's write-only property. ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server password', 'default' => '', 'example' => 'securepassword', ]) + */ ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server secure protocol', @@ -409,7 +418,8 @@ 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'] ?? ''); + $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); From 06a2d48debacc7fb4a6a928eeb48ee13ac6e5084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:29:13 +0200 Subject: [PATCH 047/123] Linter fix --- .../Project/Http/Project/SMTP/Update.php | 26 +++++++++---------- .../Modules/Project/Services/Http.php | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 3c25750959..8e636fb2e2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -94,7 +94,7 @@ class Update extends Action ): 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) { @@ -102,7 +102,7 @@ class Update extends Action $smtp[$key] = ${$key}; } } - + // Ensure required fields are set $requiredKeys = ['host', 'port', 'senderEmail']; foreach ($requiredKeys as $key) { @@ -110,33 +110,33 @@ class Update extends Action throw new \Exception('"' . $key . '" is required. Please provide a value.'); } } - + // Validate SMTP credentials - if($smtp['enabled'] === true) { + if ($smtp['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'] ?? '')) { + + if (!empty($smtp['username'] ?? '')) { $mail->SMTPAuth = true; $mail->Username = $smtp['username']; $mail->Password = $smtp['password'] ?? ''; } - - if(!empty($smtp['replyToEmail'] ?? '')) { + + 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.'); } @@ -149,7 +149,7 @@ class Update extends Action $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/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index ac048c6022..f768fb31be 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,7 +24,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Credentials\Update as UpdateSMTPCredentials; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -48,7 +48,7 @@ class Http extends Service $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // SMTP - $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Variables From bc592903dbe80932ded01d1841b2afd5cbc84395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:47:06 +0200 Subject: [PATCH 048/123] Support reply to name --- app/controllers/api/account.php | 81 +++++++++++++------ app/controllers/api/projects.php | 18 +++-- src/Appwrite/Bus/Listeners/Mails.php | 3 +- src/Appwrite/Event/Mail.php | 36 +++++++-- .../Http/Account/MFA/Challenges/Create.php | 20 +++-- .../Http/Project/SMTP/Tests/Create.php | 12 ++- .../Modules/Teams/Http/Memberships/Create.php | 20 +++-- src/Appwrite/Platform/Workers/Mails.php | 4 +- .../Utopia/Response/Model/TemplateEmail.php | 8 +- tests/e2e/Services/Project/SMTPBase.php | 33 +++++--- 10 files changed, 166 insertions(+), 69 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0035778523..3f2a5c369b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2295,8 +2295,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'])) { @@ -2305,8 +2305,11 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2323,8 +2326,11 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2332,7 +2338,8 @@ Http::post('/v1/account/tokens/magic-url') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -2611,7 +2618,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'])) { @@ -2620,8 +2628,11 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2638,8 +2649,11 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2647,7 +2661,8 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -3743,7 +3758,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 +3768,11 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -3770,8 +3789,11 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -3779,7 +3801,8 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4060,7 +4083,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'])) { @@ -4069,8 +4093,11 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -4087,8 +4114,11 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -4096,7 +4126,8 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index e964933686..fae95b78a3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -772,7 +772,9 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') 'message' => $message, 'subject' => $localeObj->getText('emails.' . $type . '.subject'), 'senderEmail' => '', - 'senderName' => '' + 'senderName' => '', + 'replyToEmail' => '', + 'replyToName' => '' ]; } @@ -873,10 +875,11 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->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) + ->param('replyToEmail', '', new Email(), 'Reply to email', true) + ->param('replyToName', '', new Text(255, 0), 'Reply to name', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyToEmail, string $replyToName, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -889,7 +892,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]; @@ -901,7 +905,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]), Response::MODEL_EMAIL_TEMPLATE); }); @@ -1026,7 +1031,8 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); }); diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 2ffcbc9aa4..54754f317c 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -131,7 +131,8 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '') + ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $smtp['replyToEmail'] ?? '') + ->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/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 20a6afed2e..1a0006de64 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 @@ -253,7 +253,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'])) { @@ -262,8 +263,11 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -280,8 +284,11 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -289,7 +296,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/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index ec6bc87717..d60cb08ffd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -104,7 +104,8 @@ class Create extends Action $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyTo = $paramReplyTo ?: ($smtp['replyTo'] ?? ''); + $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? ''); + $replyToName = $smtp['replyToName'] ?? ''; $host = $paramHost ?: ($smtp['host'] ?? ''); $port = $paramPort ?? ($smtp['port'] ?? ''); $username = $paramUsername ?: ($smtp['username'] ?? ''); @@ -127,13 +128,15 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); } - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; + // 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}}', "{$senderName} ({$replyToEmail})") + ->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) @@ -149,7 +152,8 @@ class Create extends Action ->setSmtpUsername($username) ->setSmtpPassword($password) ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName) ->setRecipient($email) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5edc69f445..4d13f125d8 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -341,7 +341,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'])) { @@ -350,8 +351,11 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (! empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (! empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -368,8 +372,11 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (! empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (! empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -377,7 +384,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 32de1e50d6..3ddbcf482e 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -173,8 +173,8 @@ 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; + $replyTo = !empty($smtp['replyToEmail']) ? $smtp['replyToEmail'] : ($smtp['senderEmail'] ?? $replyTo); + $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); } $attachments = null; diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..626a9fa368 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -22,12 +22,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/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 51e2e5e8a9..887ed864ba 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -67,7 +67,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpEnabled', $response['body']); $this->assertArrayHasKey('smtpSenderName', $response['body']); $this->assertArrayHasKey('smtpSenderEmail', $response['body']); - $this->assertArrayHasKey('smtpReplyTo', $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']); @@ -115,14 +116,16 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - replyTo: 'reply@example.com', + 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']['smtpReplyTo']); + $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']); @@ -188,7 +191,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpEnabled', $response['body']); $this->assertArrayHasKey('smtpSenderName', $response['body']); $this->assertArrayHasKey('smtpSenderEmail', $response['body']); - $this->assertArrayHasKey('smtpReplyTo', $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']); @@ -279,7 +283,7 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - replyTo: 'not-an-email', + replyToEmail: 'not-an-email', ); $this->assertSame(400, $response['headers']['status-code']); @@ -664,15 +668,17 @@ trait SMTPBase $senderName = 'SMTP Test Sender'; $senderEmail = 'smtptest@appwrite.io'; $replyToEmail = 'smtpreply@appwrite.io'; + $replyToName = 'SMTP Reply Team'; $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; - // Configure SMTP with replyTo and auth credentials + // Configure SMTP with reply-to and auth credentials $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', port: 1025, - replyTo: $replyToEmail, + replyToEmail: $replyToEmail, + replyToName: $replyToName, username: 'user', password: 'password', ); @@ -693,7 +699,7 @@ trait SMTPBase $this->assertSame($senderEmail, $email['from'][0]['address']); $this->assertSame($senderName, $email['from'][0]['name']); $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); - $this->assertSame($senderName, $email['replyTo'][0]['name']); + $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']); @@ -769,7 +775,8 @@ trait SMTPBase string $senderEmail = '', string $host = '', int $port = 587, - ?string $replyTo = null, + ?string $replyToEmail = null, + ?string $replyToName = null, ?string $username = null, ?string $password = null, ?string $secure = null, @@ -792,8 +799,12 @@ trait SMTPBase 'port' => $port, ]; - if (!\is_null($replyTo)) { - $params['replyTo'] = $replyTo; + if (!\is_null($replyToEmail)) { + $params['replyToEmail'] = $replyToEmail; + } + + if (!\is_null($replyToName)) { + $params['replyToName'] = $replyToName; } if (!\is_null($username)) { From 56385ce167cdb03a1979668d1d141b02558f54ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:48:45 +0200 Subject: [PATCH 049/123] Backwards compatibility --- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 3 +++ src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 8e636fb2e2..97295aa7b5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -102,6 +102,9 @@ class Update extends Action $smtp[$key] = ${$key}; } } + + // Backwards compatibility + $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Ensure required fields are set $requiredKeys = ['host', 'port', 'senderEmail']; diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index e1909c4785..aef4927ad7 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -418,7 +418,7 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? ''); + $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'] ?? ''); From f040a4dc319f90c49b689ea9ca96b9b8624b4be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:58:55 +0200 Subject: [PATCH 050/123] More backwards compatibility --- app/controllers/api/account.php | 78 +++++++++++-------- app/controllers/api/projects.php | 8 +- src/Appwrite/Bus/Listeners/Mails.php | 2 +- .../Http/Account/MFA/Challenges/Create.php | 12 ++- .../Http/Project/SMTP/Tests/Create.php | 2 +- .../Modules/Teams/Http/Memberships/Create.php | 12 ++- src/Appwrite/Platform/Workers/Mails.php | 4 +- 7 files changed, 75 insertions(+), 43 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 3f2a5c369b..fdb6137b9c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2305,8 +2305,10 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // 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']; @@ -2326,8 +2328,10 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // 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']; @@ -2618,8 +2622,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'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2628,9 +2632,11 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // 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']; } @@ -2649,9 +2655,11 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // 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']; } @@ -2661,7 +2669,7 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); @@ -3758,8 +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'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -3768,9 +3776,11 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // 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']; } @@ -3789,9 +3799,11 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // 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']; } @@ -3801,7 +3813,7 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); @@ -4083,8 +4095,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'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -4093,9 +4105,11 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // 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']; } @@ -4114,9 +4128,11 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // 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']; } @@ -4126,7 +4142,7 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index fae95b78a3..9f5cedac9f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -716,6 +716,12 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; + // Includes backwards compatibility: fall back to legacy `replyTo` key + if (!is_null($template)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + $template['replyToName'] = $template['replyToName'] ?? ''; + } + $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -1031,7 +1037,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility 'replyToName' => $template['replyToName'] ?? '', 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 54754f317c..68dbe1d89b 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -131,7 +131,7 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $smtp['replyToEmail'] ?? '') + ->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/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 1a0006de64..b53c19daa6 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 @@ -263,8 +263,10 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // 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']; @@ -284,8 +286,10 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // 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']; 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 index d60cb08ffd..b61147aef6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -104,7 +104,7 @@ class Create extends Action $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? ''); + $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility $replyToName = $smtp['replyToName'] ?? ''; $host = $paramHost ?: ($smtp['host'] ?? ''); $port = $paramPort ?? ($smtp['port'] ?? ''); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 4d13f125d8..b2bec33221 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -351,8 +351,10 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // 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']; @@ -372,8 +374,10 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // 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']; diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 3ddbcf482e..04c588b2c8 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -173,7 +173,9 @@ class Mails extends Action $replyTo = $customMailOptions['replyToEmail'] ?? $replyTo; $replyToName = $customMailOptions['replyToName'] ?? $replyToName; } elseif (!empty($smtp)) { - $replyTo = !empty($smtp['replyToEmail']) ? $smtp['replyToEmail'] : ($smtp['senderEmail'] ?? $replyTo); + // 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); } From 78ef52cc9e45a37e84ca61e4e34b76d0adc99bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 13:11:11 +0200 Subject: [PATCH 051/123] Manual QA fixes --- docker-compose.yml | 2 +- .../Modules/Project/Http/Project/SMTP/Tests/Create.php | 3 --- .../Modules/Project/Http/Project/SMTP/Update.php | 10 ++++++++-- .../Platform/Modules/Project/Services/Http.php | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index aa2bfdd16a..14f00205ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -254,7 +254,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/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index b61147aef6..a41292466c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -40,9 +40,6 @@ class Create extends Action ->desc('Create project SMTP test') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( namespace: 'project', group: 'smtp', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 97295aa7b5..58360058da 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -102,7 +102,7 @@ class Update extends Action $smtp[$key] = ${$key}; } } - + // Backwards compatibility $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; @@ -110,7 +110,7 @@ class Update extends Action $requiredKeys = ['host', 'port', 'senderEmail']; foreach ($requiredKeys as $key) { if (empty($smtp[$key])) { - throw new \Exception('"' . $key . '" is required. Please provide a value.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, '"' . $key . '" is required. Please provide a value.'); } } @@ -143,6 +143,12 @@ class Update extends Action 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) { throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f768fb31be..e9e30f7590 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,8 +24,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; 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\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; From 2097b0a0b0d7a5f968e72baec6769c4be617d14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:04:53 +0200 Subject: [PATCH 052/123] Better support for post-smtp changes --- .../Http/Project/Templates/Email/Delete.php | 20 +++++++++++++------ .../Http/Project/Templates/Email/Get.php | 8 ++++++++ .../Http/Project/Templates/Email/Update.php | 12 +++++++---- .../Utopia/Response/Model/TemplateEmail.php | 8 +++++++- tests/e2e/Services/Project/TemplatesBase.php | 12 +++++++---- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 176e8d7d63..7928486192 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; @@ -50,11 +49,10 @@ class Delete extends Action auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, + code: Response::STATUS_CODE_OK, + model: Response::MODEL_EMAIL_TEMPLATE, ) - ], - contentType: ContentType::NONE + ] )) ->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']) @@ -96,6 +94,16 @@ class Delete extends Action $queueForEvents->setParam('templateType', $templateId); - $response->noContent(); + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'subject' => $template['subject'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility + 'replyToName' => $template['replyToName'] ?? '', + 'message' => $template['message'] ?? '', + 'custom' => true, + ]), Response::MODEL_EMAIL_TEMPLATE); } } 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 index 115b10f7dd..6e2b2ef56d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -67,6 +67,12 @@ class Get extends Action $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? null; + // Includes backwards compatibility: fall back to legacy `replyTo` key + if (!is_null($template)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + $template['replyToName'] = $template['replyToName'] ?? ''; + } + $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -124,6 +130,8 @@ class Get extends Action 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', + 'replyToEmail' => '', + 'replyToName' => '', 'custom' => false, ]; } else { 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 index f17be381f6..93cfa7a3fb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -61,7 +61,8 @@ class Update extends Action ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') ->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('replyToEmail', '', new Email(), 'Reply to email.', true) + ->param('replyToName', '', new Text(255, 0), 'Reply to name.', true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -78,7 +79,8 @@ class Update extends Action string $message, string $senderName, string $senderEmail, - string $replyTo, + string $replyToEmail, + string $replyToName, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -92,7 +94,8 @@ class Update extends Action 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]; @@ -113,7 +116,8 @@ class Update extends Action 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], + 'replyToEmail' => $template['replyToEmail'], + 'replyToName' => $template['replyToName'], 'message' => $template['message'], 'custom' => true, ]), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 9b77617b8c..48cd0ba556 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -40,12 +40,18 @@ class TemplateEmail extends Model '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' => 'My User', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 4f78992079..422f890785 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -119,7 +119,7 @@ trait TemplatesBase $this->assertSame('You have been invited', $update['body']['message']); $this->assertSame('Appwrite Team', $update['body']['senderName']); $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); - $this->assertSame('reply@appwrite.io', $update['body']['replyTo']); + $this->assertSame('reply@appwrite.io', $update['body']['replyToEmail']); // Cleanup $this->deleteEmailTemplate('invitation', 'en'); @@ -464,7 +464,8 @@ trait TemplatesBase ?string $message, ?string $senderName = null, ?string $senderEmail = null, - ?string $replyTo = null, + ?string $replyToEmail = null, + ?string $replyToName = null, bool $authenticated = true, ): mixed { $params = [ @@ -486,8 +487,11 @@ trait TemplatesBase if ($senderEmail !== null) { $params['senderEmail'] = $senderEmail; } - if ($replyTo !== null) { - $params['replyTo'] = $replyTo; + if ($replyToEmail !== null) { + $params['replyToEmail'] = $replyToEmail; + } + if ($replyToName !== null) { + $params['replyToName'] = $replyToName; } $headers = [ From afb8f7031633a431e7bf89194e7ab212c21b60a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:42:52 +0200 Subject: [PATCH 053/123] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 251 ++++++++++++------------ 1 file changed, 130 insertions(+), 121 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 887ed864ba..2cfe645290 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -11,21 +11,33 @@ trait SMTPBase public function testUpdateSMTPStatusEnable(): void { - $response = $this->updateSMTPStatus(true); + $response = $this->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->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusDisable(): void { - $this->updateSMTPStatus(true); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); - $response = $this->updateSMTPStatus(false); + $response = $this->updateSMTP(enabled: false); $this->assertSame(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); @@ -34,32 +46,50 @@ trait SMTPBase public function testUpdateSMTPStatusEnableIdempotent(): void { - $first = $this->updateSMTPStatus(true); + $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->updateSMTPStatus(true); + $second = $this->updateSMTP(enabled: true); $this->assertSame(200, $second['headers']['status-code']); $this->assertSame(true, $second['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusDisableIdempotent(): void { - $first = $this->updateSMTPStatus(false); + $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->updateSMTPStatus(false); + $second = $this->updateSMTP(enabled: false); $this->assertSame(200, $second['headers']['status-code']); $this->assertSame(false, $second['body']['smtpEnabled']); } public function testUpdateSMTPStatusResponseModel(): void { - $response = $this->updateSMTPStatus(true); + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); $this->assertSame(200, $response['headers']['status-code']); $this->assertArrayHasKey('$id', $response['body']); @@ -76,12 +106,12 @@ trait SMTPBase $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusWithoutAuthentication(): void { - $response = $this->updateSMTPStatus(true, false); + $response = $this->updateSMTP(enabled: true, authenticated: false); $this->assertSame(401, $response['headers']['status-code']); } @@ -90,7 +120,7 @@ trait SMTPBase public function testUpdateSMTPCredentials(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -106,12 +136,12 @@ trait SMTPBase $this->assertSame(1025, $response['body']['smtpPort']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPWithOptionalReplyTo(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Full Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -130,19 +160,19 @@ trait SMTPBase $this->assertSame(1025, $response['body']['smtpPort']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPOverwritesPreviousSettings(): void { - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'First Sender', senderEmail: 'first@example.com', host: 'maildev', port: 1025, ); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Second Sender', senderEmail: 'second@example.com', host: 'maildev', @@ -154,15 +184,21 @@ trait SMTPBase $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPEnablesSMTP(): void { // Ensure SMTP is disabled - $this->updateSMTPStatus(false); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -173,12 +209,12 @@ trait SMTPBase $this->assertSame(true, $response['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPResponseModel(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -200,12 +236,12 @@ trait SMTPBase $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPWithoutAuthentication(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -218,7 +254,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSenderEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'not-an-email', host: 'maildev', @@ -230,7 +266,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderName(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: '', senderEmail: 'sender@example.com', host: 'maildev', @@ -242,7 +278,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: '', host: 'maildev', @@ -254,7 +290,7 @@ trait SMTPBase public function testUpdateSMTPEmptyHost(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: '', @@ -266,7 +302,7 @@ trait SMTPBase public function testUpdateSMTPInvalidHost(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'not a valid host!@#', @@ -278,7 +314,7 @@ trait SMTPBase public function testUpdateSMTPInvalidReplyToEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -291,7 +327,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSecure(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -304,7 +340,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'A', senderEmail: 'sender@example.com', host: 'maildev', @@ -315,13 +351,13 @@ trait SMTPBase $this->assertSame('A', $response['body']['smtpSenderName']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPSenderNameMaxLength(): void { $name = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $name, senderEmail: 'sender@example.com', host: 'maildev', @@ -332,12 +368,12 @@ trait SMTPBase $this->assertSame($name, $response['body']['smtpSenderName']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPSenderNameTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: str_repeat('a', 257), senderEmail: 'sender@example.com', host: 'maildev', @@ -349,7 +385,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -361,13 +397,13 @@ trait SMTPBase $this->assertSame('u', $response['body']['smtpUsername']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPUsernameMaxLength(): void { $username = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -379,12 +415,12 @@ trait SMTPBase $this->assertSame($username, $response['body']['smtpUsername']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPUsernameTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -397,7 +433,7 @@ trait SMTPBase public function testUpdateSMTPUsernameEmpty(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -410,7 +446,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -422,13 +458,13 @@ trait SMTPBase $this->assertSame('p', $response['body']['smtpPassword']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPPasswordMaxLength(): void { $password = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -440,12 +476,12 @@ trait SMTPBase $this->assertSame($password, $response['body']['smtpPassword']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPPasswordTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -458,7 +494,7 @@ trait SMTPBase public function testUpdateSMTPPasswordEmpty(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -471,7 +507,7 @@ trait SMTPBase public function testUpdateSMTPWithoutSecure(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -482,12 +518,12 @@ trait SMTPBase $this->assertSame('', $response['body']['smtpSecure']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPInvalidConnectionRefused(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', @@ -501,10 +537,16 @@ trait SMTPBase public function testUpdateSMTPBackwardsCompatibilityDisable(): void { // First enable SMTP - $this->updateSMTPStatus(true); + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); // Use the deprecated enabled=false parameter to disable - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -521,7 +563,7 @@ trait SMTPBase public function testCreateSMTPTest(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -534,13 +576,13 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestMultipleRecipients(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -557,13 +599,19 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestWhenSMTPDisabled(): void { // Ensure SMTP is disabled - $this->updateSMTPStatus(false); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); $response = $this->createSMTPTest(['recipient@example.com']); @@ -580,7 +628,7 @@ trait SMTPBase public function testCreateSMTPTestEmptyEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -593,13 +641,13 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestInvalidEmail(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -611,13 +659,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestExceedsMaxEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -634,13 +682,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestMaxEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -658,7 +706,7 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } // Integration tests @@ -672,7 +720,7 @@ trait SMTPBase $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; // Configure SMTP with reply-to and auth credentials - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -705,7 +753,7 @@ trait SMTPBase $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testMagicURLLoginUsesCustomSMTP(): void @@ -715,7 +763,7 @@ trait SMTPBase $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; // Configure custom SMTP with auth credentials - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -749,32 +797,16 @@ trait SMTPBase $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } // Helpers - protected function updateSMTPStatus(bool $enabled, 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_PATCH, '/project/smtp/status', $headers, [ - 'enabled' => $enabled, - ]); - } - - protected function updateSMTPCredentials( - string $senderName = '', - string $senderEmail = '', - string $host = '', - int $port = 587, + protected function updateSMTP( + ?string $senderName = null, + ?string $senderEmail = null, + ?string $host = null, + ?int $port = null, ?string $replyToEmail = null, ?string $replyToName = null, ?string $username = null, @@ -792,38 +824,15 @@ trait SMTPBase $headers = array_merge($headers, $this->getHeaders()); } - $params = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'host' => $host, - 'port' => $port, - ]; + $params = []; - if (!\is_null($replyToEmail)) { - $params['replyToEmail'] = $replyToEmail; + foreach (['senderName', 'senderEmail', 'host', 'port', 'replyToEmail', 'replyToName', 'username', 'password', 'secure', 'enabled'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } } - if (!\is_null($replyToName)) { - $params['replyToName'] = $replyToName; - } - - if (!\is_null($username)) { - $params['username'] = $username; - } - - if (!\is_null($password)) { - $params['password'] = $password; - } - - if (!\is_null($secure)) { - $params['secure'] = $secure; - } - - if (!\is_null($enabled)) { - $params['enabled'] = $enabled; - } - - return $this->client->call(Client::METHOD_PATCH, '/project/smtp/credentials', $headers, $params); + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); } /** From 1c1ec4315075d1d6e696c5e8a14e0b4c5adeba0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:47:01 +0200 Subject: [PATCH 054/123] Removeal post-merge --- app/controllers/api/projects.php | 214 ------------------------------- 1 file changed, 214 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..6fc52d2158 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -619,220 +619,6 @@ 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') From 8b41aed9196e94ddb06bd757cae1d1c6c611c23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:49:43 +0200 Subject: [PATCH 055/123] Post-merge removal --- app/controllers/api/projects.php | 218 ------------------------------- 1 file changed, 218 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 6fc52d2158..5aeee1a8d7 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -619,224 +619,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Delete custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) From 52e3319a867eba490ef0325cd3af1e31113f8dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:50:12 +0200 Subject: [PATCH 056/123] Linter fix --- app/controllers/api/projects.php | 8 -------- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5aeee1a8d7..562ff15c39 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; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 58360058da..edfb45a6e6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -143,10 +143,10 @@ class Update extends Action 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)) { + if (\is_null($enabled)) { $smtp['enabled'] = true; } } catch (Throwable $error) { From 51f50b161c4a1f050fc1a652aa7a340a26e4318c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 15:28:05 +0200 Subject: [PATCH 057/123] Improved backwards compatibility --- .../Modules/Project/Services/Http.php | 1 + src/Appwrite/Utopia/Request/Filters/V23.php | 18 +++++++++++++++- src/Appwrite/Utopia/Response/Filters/V23.php | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 2e7ceafe14..b2ee23aca6 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -53,6 +53,7 @@ class Http extends Service // SMTP $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + // Templates $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index adb5d69aea..131844e6d5 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -17,14 +17,30 @@ class V23 extends Filter return $content; } + protected function parseReplyTo(array $content): array + { + if (isset($content['replyTo'])) { + $content['replyToEmail'] = $content['replyTo']; + unset($content['replyTo']); + } + + return $content; + } + public function parse(array $content, string $model): array { switch ($model) { case 'project.getEmailTemplate': - case 'project.updateEmailTemplate': case 'project.deleteEmailTemplate': $content = $this->parseEmailTemplate($content); break; + case 'project.updateEmailTemplate': + $content = $this->parseEmailTemplate($content); + $content = $this->parseReplyTo($content); + break; + case 'project.updateSMTP': + $content = $this->parseReplyTo($content); + break; } return $content; } diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php index 54fcf8459f..8303e98eed 100644 --- a/src/Appwrite/Utopia/Response/Filters/V23.php +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -12,6 +12,7 @@ class V23 extends Filter { return match ($model) { Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + Response::MODEL_PROJECT => $this->parseProject($content), default => $content, }; } @@ -23,6 +24,26 @@ class V23 extends Filter 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 + { + if (isset($content['smtpReplyToEmail'])) { + $content['smtpReplyTo'] = $content['smtpReplyToEmail']; + unset($content['smtpReplyToEmail']); + } + + unset($content['smtpReplyToName']); + return $content; } } From 848f09956eced39a4e1802ea228959c66429c55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 15:36:49 +0200 Subject: [PATCH 058/123] Improve backwards compatibility test coverage --- .../Http/Project/SMTP/Tests/Create.php | 34 +++++-- tests/e2e/Services/Project/SMTPBase.php | 98 +++++++++++++++++++ tests/e2e/Services/Project/TemplatesBase.php | 96 ++++++++++++++++++ 3 files changed, 218 insertions(+), 10 deletions(-) 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 index a41292466c..a07d667d56 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -90,7 +90,9 @@ class Create extends Action Mail $queueForMails, array $plan ): void { - // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config + // 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', []); @@ -99,15 +101,27 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); } - $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); - $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility - $replyToName = $smtp['replyToName'] ?? ''; - $host = $paramHost ?: ($smtp['host'] ?? ''); - $port = $paramPort ?? ($smtp['port'] ?? ''); - $username = $paramUsername ?: ($smtp['username'] ?? ''); - $password = $paramPassword ?: ($smtp['password'] ?? ''); - $secure = $paramSecure ?: ($smtp['secure'] ?? ''); + 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($senderName)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 2cfe645290..c6f7ea4b67 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -534,6 +534,104 @@ trait SMTPBase $this->assertSame('project_smtp_config_invalid', $response['body']['type']); } + 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 diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 422f890785..ade2bb3f2c 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -434,6 +434,102 @@ trait TemplatesBase $this->assertSame(400, $update['headers']['status-code']); } + public function testUpdateEmailTemplateLegacyReplyTo(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy clients send replyTo (not replyToEmail) — request filter maps it. + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'invitation', + 'locale' => 'en', + 'subject' => 'Legacy reply-to subject', + 'message' => 'Legacy reply-to body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $update['headers']['status-code']); + // Response filter should rename replyToEmail -> replyTo, strip replyToName / custom. + $this->assertArrayHasKey('replyTo', $update['body']); + $this->assertArrayNotHasKey('replyToEmail', $update['body']); + $this->assertArrayNotHasKey('replyToName', $update['body']); + $this->assertArrayNotHasKey('custom', $update['body']); + $this->assertSame('legacy-reply@appwrite.io', $update['body']['replyTo']); + $this->assertSame('Legacy Sender', $update['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $update['body']['senderEmail']); + + // Verify value is persisted and readable via the legacy GET shape. + $get = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/invitation', + $headers, + ['locale' => 'en'], + ); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('replyTo', $get['body']); + $this->assertArrayNotHasKey('replyToEmail', $get['body']); + $this->assertArrayNotHasKey('replyToName', $get['body']); + $this->assertArrayNotHasKey('custom', $get['body']); + $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); + + // Cleanup + $this->deleteEmailTemplate('invitation', 'en'); + } + + public function testGetEmailTemplateLegacyReplyTo(): void + { + // Seed a custom template using the current API (includes replyToEmail + replyToName). + $update = $this->updateEmailTemplate( + 'otpSession', + 'en', + 'Legacy OTP', + 'Legacy OTP body', + 'Legacy Sender', + 'legacy-sender@appwrite.io', + 'legacy-reply@appwrite.io', + 'Legacy Reply Team', + ); + $this->assertSame(200, $update['headers']['status-code']); + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $get = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/otpSession', + $headers, + ['locale' => 'en'], + ); + + $this->assertSame(200, $get['headers']['status-code']); + // Legacy fields present + $this->assertArrayHasKey('type', $get['body']); + $this->assertArrayHasKey('replyTo', $get['body']); + $this->assertSame('otpSession', $get['body']['type']); + $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); + // New fields stripped + $this->assertArrayNotHasKey('templateId', $get['body']); + $this->assertArrayNotHasKey('replyToEmail', $get['body']); + $this->assertArrayNotHasKey('replyToName', $get['body']); + $this->assertArrayNotHasKey('custom', $get['body']); + + // Cleanup + $this->deleteEmailTemplate('otpSession', 'en'); + } + // ========================================================================= // Helpers // ========================================================================= From dfec2b3cb7787ee58207199f862697fcc0bfc46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 16:11:22 +0200 Subject: [PATCH 059/123] Improve test coverage --- .../Http/Project/SMTP/Tests/Create.php | 4 - .../Project/Http/Project/SMTP/Update.php | 6 +- tests/e2e/Services/Project/SMTPBase.php | 150 ++++++++++++++++++ 3 files changed, 154 insertions(+), 6 deletions(-) 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 index a07d667d56..7095c2d2d0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -123,10 +123,6 @@ class Create extends Action $secure = $smtp['secure'] ?? ''; } - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); - } - if (empty($senderEmail)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index edfb45a6e6..63e9cf287e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -115,7 +115,7 @@ class Update extends Action } // Validate SMTP credentials - if ($smtp['enabled'] === true) { + if (\is_null($smtp['enabled'] ?? null) || $smtp['enabled'] === true) { $mail = new PHPMailer(true); $mail->isSMTP(); @@ -150,7 +150,9 @@ class Update extends Action $smtp['enabled'] = true; } } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + if (($smtp['enabled'] ?? null) === true) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } } } diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c6f7ea4b67..c8cb06ac4e 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -656,6 +656,156 @@ trait SMTPBase $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 From 8ea69e03212ecbdc53f8c23accfcf01f4e103815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 16:57:23 +0200 Subject: [PATCH 060/123] Fix password visibility test --- src/Appwrite/Utopia/Response/Model/Project.php | 9 +++------ tests/e2e/Services/Project/SMTPBase.php | 14 ++++++++++++-- .../Projects/ProjectsConsoleClientTest.php | 6 ++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index aef4927ad7..11e23b02d8 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -277,15 +277,12 @@ class Project extends Model 'default' => '', 'example' => 'emailuser', ]) - /* - We intentionally do not expose SMTP password - it's write-only property. ->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, 'description' => 'SMTP server secure protocol', @@ -423,7 +420,7 @@ class Project extends Model $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); - $document->setAttribute('smtpPassword', $smtp['password'] ?? ''); + $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); } diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c8cb06ac4e..fa58891b08 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -88,6 +88,8 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, + username: 'user', + password: 'password', enabled: true, ); @@ -103,6 +105,8 @@ trait SMTPBase $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 @@ -219,6 +223,8 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); @@ -233,6 +239,8 @@ trait SMTPBase $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 @@ -455,7 +463,8 @@ trait SMTPBase ); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('p', $response['body']['smtpPassword']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); // Cleanup $this->updateSMTP(enabled: false); @@ -473,7 +482,8 @@ trait SMTPBase ); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($password, $response['body']['smtpPassword']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); // Cleanup $this->updateSMTP(enabled: false); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 4400c337ac..9506c1a963 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -971,7 +971,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); // Check the project @@ -987,7 +988,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']); /** From 8c03db70e9484e5eeaeb688a70e721bce06f3a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:04:20 +0200 Subject: [PATCH 061/123] Finalize templates design --- app/config/errors.php | 5 + src/Appwrite/Extend/Exception.php | 1 + .../Http/Project/Platforms/Web/Create.php | 2 +- .../Http/Project/Templates/Email/Delete.php | 109 ---- .../Http/Project/Templates/Email/Get.php | 144 ++-- .../Http/Project/Templates/Email/Update.php | 67 +- .../Modules/Project/Services/Http.php | 2 - .../Utopia/Response/Model/TemplateEmail.php | 6 - tests/e2e/Services/Project/TemplatesBase.php | 617 ------------------ 9 files changed, 119 insertions(+), 834 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php diff --git a/app/config/errors.php b/app/config/errors.php index 4190c6e277..1d9d4593b9 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,6 +1144,11 @@ return [ 'description' => 'Provided SMTP config is invalid. Please check the configured values and try again.', 'code' => 400, ], + Exception::PROJECT_TEMPLATE_CONFIG_INVALID => [ + 'name' => Exception::PROJECT_TEMPLATE_CONFIG_INVALID, + 'description' => 'Provided template config is invalid. Please check the configured values and try again.', + 'code' => 400, + ], Exception::PROJECT_TEMPLATE_DEFAULT_DELETION => [ 'name' => Exception::PROJECT_TEMPLATE_DEFAULT_DELETION, 'description' => 'You can\'t delete default template. If you are trying to reset your template changes, you can ignore this error as it\'s already been reset.', diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 58a21b5517..b2eb06b752 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -303,6 +303,7 @@ class Exception extends \Exception public const string ACCOUNT_KEY_EXPIRED = 'account_key_expired'; public const string PROJECT_SMTP_CONFIG_INVALID = 'project_smtp_config_invalid'; + public const string PROJECT_TEMPLATE_CONFIG_INVALID = 'project_template_config_invalid'; public const string PROJECT_TEMPLATE_DEFAULT_DELETION = 'project_template_default_deletion'; 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/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php deleted file mode 100644 index 7928486192..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ /dev/null @@ -1,109 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) - ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') - ->desc('Delete project email template') - ->groups(['api', 'project']) - ->label('scope', 'templates.write') - ->label('event', 'templates.[templateType].delete') - ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{request.templateId}') - ->label('sdk', new Method( - namespace: 'project', - group: 'templates', - name: 'deleteEmailTemplate', - 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('queueForEvents') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('locale') - ->callback($this->action(...)); - } - - public function action( - string $templateId, - string $locale, - Response $response, - QueueEvent $queueForEvents, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - Locale $localeObject, - ) { - $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $templateId . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $templateId . '-' . $locale]); - - $updates = new Document([ - 'templates' => $templates, - ]); - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - - $queueForEvents->setParam('templateType', $templateId); - - $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'senderName' => $template['senderName'] ?? '', - 'senderEmail' => $template['senderEmail'] ?? '', - 'subject' => $template['subject'] ?? '', - 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility - 'replyToName' => $template['replyToName'] ?? '', - 'message' => $template['message'] ?? '', - 'custom' => true, - ]), Response::MODEL_EMAIL_TEMPLATE); - } -} 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 index 6e2b2ef56d..1843c556d4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -51,7 +51,6 @@ class Get extends Action ->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') - ->inject('locale') ->callback($this->action(...)); } @@ -60,87 +59,84 @@ class Get extends Action string $locale, Response $response, Document $project, - Locale $localeObject, ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + // Get custom template if available $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $templateId . '-' . $locale] ?? null; - - // Includes backwards compatibility: fall back to legacy `replyTo` key - if (!is_null($template)) { - $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; - $template['replyToName'] = $template['replyToName'] ?? ''; - } - - $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[$templateId] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/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.{$templateId}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), - 'senderEmail' => '', - 'senderName' => '', - 'replyToEmail' => '', - 'replyToName' => '', - 'custom' => false, - ]; - } else { - $template['custom'] = true; - } + $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'])) { + $template['message'] = $defaultMessage; + } + + if (\is_null($template['subject'])) { + $template['subject'] = $defaultSubject; + } + + // Backwards compatibility + if (!\is_null($template['replyTo'])) { + $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 index 93cfa7a3fb..4a1d4a5a21 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; use Appwrite\Event\Event as QueueEvent; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -12,10 +13,10 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; -use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -57,51 +58,68 @@ class Update extends Action )) ->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', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') - ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) - ->param('senderEmail', '', new Email(), 'Email of the sender.', true) - ->param('replyToEmail', '', new Email(), 'Reply to email.', true) - ->param('replyToName', '', new Text(255, 0), 'Reply to name.', true) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.') + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', true) + ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('authorization') ->inject('project') - ->inject('locale') ->callback($this->action(...)); } public function action( string $templateId, string $locale, - string $subject, - string $message, - string $senderName, - string $senderEmail, - string $replyToEmail, - string $replyToName, + ?string $subject, + ?string $message, + ?string $senderName, + ?string $senderEmail, + ?string $replyToEmail, + ?string $replyToName, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, Authorization $authorization, Document $project, - Locale $localeObject, ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); - $template = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyToEmail' => $replyToEmail, - 'replyToName' => $replyToName, - 'message' => $message - ]; + // 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', []); - $templates['email.' . $templateId . '-' . $locale] = $template; + $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 + $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, ]); @@ -119,7 +137,6 @@ class Update extends Action 'replyToEmail' => $template['replyToEmail'], 'replyToName' => $template['replyToName'], 'message' => $template['message'], - 'custom' => true, ]), 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 b2ee23aca6..5774b377c9 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -26,7 +26,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdatePro 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\Delete as DeleteTemplate; 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; @@ -56,7 +55,6 @@ class Http extends Service // Templates $this->addAction(GetTemplate::getName(), new GetTemplate()); - $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); // Variables diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 08ccd33524..833de90065 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -58,12 +58,6 @@ class TemplateEmail extends Model 'default' => '', 'example' => 'Please verify your email address', ]) - ->addRule('custom', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'Whether the template has been customized for the project. Non-custom templates render from defaults.', - 'default' => false, - 'example' => false, - ]) ; } diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index ade2bb3f2c..b53db7b6eb 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -6,621 +6,4 @@ use Tests\E2E\Client; trait TemplatesBase { - // ========================================================================= - // Get email template tests - // ========================================================================= - - public function testGetEmailTemplateDefault(): void - { - $template = $this->getEmailTemplate('verification', 'en'); - - $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['templateId']); - $this->assertSame('en', $template['body']['locale']); - $this->assertFalse($template['body']['custom']); - $this->assertNotEmpty($template['body']['subject']); - $this->assertNotEmpty($template['body']['message']); - } - - public function testGetEmailTemplateDefaultLocale(): void - { - $template = $this->getEmailTemplate('verification'); - - $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['templateId']); - $this->assertSame('en', $template['body']['locale']); - $this->assertFalse($template['body']['custom']); - } - - public function testGetEmailTemplateCustom(): void - { - $update = $this->updateEmailTemplate('magicSession', 'en', 'Magic Subject', 'Magic Body'); - $this->assertSame(200, $update['headers']['status-code']); - - $get = $this->getEmailTemplate('magicSession', 'en'); - - $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('magicSession', $get['body']['templateId']); - $this->assertSame('en', $get['body']['locale']); - $this->assertTrue($get['body']['custom']); - $this->assertSame('Magic Subject', $get['body']['subject']); - $this->assertSame('Magic Body', $get['body']['message']); - - // Cleanup - $this->deleteEmailTemplate('magicSession', 'en'); - } - - public function testGetEmailTemplateInvalidType(): void - { - $template = $this->getEmailTemplate('notATemplate', 'en'); - - $this->assertSame(400, $template['headers']['status-code']); - } - - public function testGetEmailTemplateInvalidLocale(): void - { - $template = $this->getEmailTemplate('verification', 'not-a-locale'); - - $this->assertSame(400, $template['headers']['status-code']); - } - - public function testGetEmailTemplateWithoutAuthentication(): void - { - $template = $this->getEmailTemplate('verification', 'en', false); - - $this->assertSame(401, $template['headers']['status-code']); - } - - // ========================================================================= - // Update email template tests - // ========================================================================= - - public function testUpdateEmailTemplate(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Please verify your email', - 'Click here to verify: {{url}}', - ); - - $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('verification', $update['body']['templateId']); - $this->assertSame('en', $update['body']['locale']); - $this->assertSame('Please verify your email', $update['body']['subject']); - $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); - $this->assertTrue($update['body']['custom']); - - // Verify persisted via GET - $get = $this->getEmailTemplate('verification', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('Please verify your email', $get['body']['subject']); - $this->assertSame('Click here to verify: {{url}}', $get['body']['message']); - $this->assertTrue($get['body']['custom']); - - // Cleanup - $this->deleteEmailTemplate('verification', 'en'); - } - - public function testUpdateEmailTemplateWithOptionalFields(): void - { - $update = $this->updateEmailTemplate( - 'invitation', - 'en', - 'Team invitation', - 'You have been invited', - 'Appwrite Team', - 'team@appwrite.io', - 'reply@appwrite.io', - ); - - $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('Team invitation', $update['body']['subject']); - $this->assertSame('You have been invited', $update['body']['message']); - $this->assertSame('Appwrite Team', $update['body']['senderName']); - $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); - $this->assertSame('reply@appwrite.io', $update['body']['replyToEmail']); - - // Cleanup - $this->deleteEmailTemplate('invitation', 'en'); - } - - public function testUpdateEmailTemplateDefaultLocale(): void - { - $update = $this->updateEmailTemplate( - 'sessionAlert', - null, - 'Session alert', - 'Someone signed in', - ); - - $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('sessionAlert', $update['body']['templateId']); - $this->assertSame('en', $update['body']['locale']); - - // Cleanup - $this->deleteEmailTemplate('sessionAlert', 'en'); - } - - public function testUpdateEmailTemplateOverwrite(): void - { - $this->updateEmailTemplate('otpSession', 'en', 'First', 'First body'); - - $second = $this->updateEmailTemplate('otpSession', 'en', 'Second', 'Second body'); - - $this->assertSame(200, $second['headers']['status-code']); - $this->assertSame('Second', $second['body']['subject']); - $this->assertSame('Second body', $second['body']['message']); - - $get = $this->getEmailTemplate('otpSession', 'en'); - $this->assertSame('Second', $get['body']['subject']); - - // Cleanup - $this->deleteEmailTemplate('otpSession', 'en'); - } - - public function testUpdateEmailTemplateInvalidType(): void - { - $update = $this->updateEmailTemplate('notATemplate', 'en', 'Subject', 'Message'); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateMissingSubject(): void - { - $update = $this->updateEmailTemplate('verification', 'en', null, 'Message only'); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateMissingMessage(): void - { - $update = $this->updateEmailTemplate('verification', 'en', 'Subject only', null); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateInvalidSenderEmail(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Subject', - 'Message', - 'Sender', - 'not-an-email', - ); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateInvalidReplyTo(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Subject', - 'Message', - null, - null, - 'not-an-email', - ); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateWithoutAuthentication(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Subject', - 'Message', - null, - null, - null, - false, - ); - - $this->assertSame(401, $update['headers']['status-code']); - } - - // ========================================================================= - // Delete email template tests - // ========================================================================= - - public function testDeleteEmailTemplate(): void - { - $update = $this->updateEmailTemplate('mfaChallenge', 'en', 'MFA', 'Enter code'); - $this->assertSame(200, $update['headers']['status-code']); - - $customBefore = $this->getEmailTemplate('mfaChallenge', 'en'); - $this->assertTrue($customBefore['body']['custom']); - - $delete = $this->deleteEmailTemplate('mfaChallenge', 'en'); - $this->assertSame(204, $delete['headers']['status-code']); - $this->assertEmpty($delete['body']); - - // Verify reset back to default - $after = $this->getEmailTemplate('mfaChallenge', 'en'); - $this->assertSame(200, $after['headers']['status-code']); - $this->assertFalse($after['body']['custom']); - $this->assertNotSame('MFA', $after['body']['subject']); - } - - public function testDeleteEmailTemplateDefault(): void - { - // Attempt to delete a template that was never customized - $delete = $this->deleteEmailTemplate('verification', 'fr'); - - $this->assertSame(401, $delete['headers']['status-code']); - $this->assertSame('project_template_default_deletion', $delete['body']['type']); - } - - public function testDeleteEmailTemplateInvalidType(): void - { - $delete = $this->deleteEmailTemplate('notATemplate', 'en'); - - $this->assertSame(400, $delete['headers']['status-code']); - } - - public function testDeleteEmailTemplateWithoutAuthentication(): void - { - $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery', 'Reset password'); - $this->assertSame(200, $update['headers']['status-code']); - - $delete = $this->deleteEmailTemplate('recovery', 'en', false); - - $this->assertSame(401, $delete['headers']['status-code']); - - // Verify still customized - $get = $this->getEmailTemplate('recovery', 'en'); - $this->assertTrue($get['body']['custom']); - - // Cleanup - $this->deleteEmailTemplate('recovery', 'en'); - } - - // ========================================================================= - // Legacy response format tests (request + response filters) - // ========================================================================= - - public function testGetEmailTemplateLegacyResponseFormat(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $template = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/verification', - $headers, - ); - - $this->assertSame(200, $template['headers']['status-code']); - // Response filter should rename templateId -> type for < 1.9.2 clients. - $this->assertArrayHasKey('type', $template['body']); - $this->assertArrayNotHasKey('templateId', $template['body']); - $this->assertSame('verification', $template['body']['type']); - $this->assertSame('en', $template['body']['locale']); - } - - public function testUpdateEmailTemplateLegacyResponseFormat(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - // Request filter should accept legacy `type` and map it to `templateId`. - $update = $this->client->call( - Client::METHOD_PATCH, - '/project/templates/email', - $headers, - [ - 'type' => 'magicSession', - 'locale' => 'en', - 'subject' => 'Legacy Subject', - 'message' => 'Legacy Body', - ], - ); - - $this->assertSame(200, $update['headers']['status-code']); - // Response filter should rename templateId -> type for < 1.9.2 clients. - $this->assertArrayHasKey('type', $update['body']); - $this->assertArrayNotHasKey('templateId', $update['body']); - $this->assertSame('magicSession', $update['body']['type']); - $this->assertSame('Legacy Subject', $update['body']['subject']); - $this->assertSame('Legacy Body', $update['body']['message']); - $this->assertTrue($update['body']['custom']); - - // Verify persisted, then cleanup via legacy DELETE with `type`. - $get = $this->getEmailTemplate('magicSession', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertTrue($get['body']['custom']); - - $delete = $this->client->call( - Client::METHOD_DELETE, - '/project/templates/email', - $headers, - [ - 'type' => 'magicSession', - 'locale' => 'en', - ], - ); - $this->assertSame(204, $delete['headers']['status-code']); - - $after = $this->getEmailTemplate('magicSession', 'en'); - $this->assertFalse($after['body']['custom']); - } - - public function testDeleteEmailTemplateLegacyResponseFormat(): void - { - // Seed a custom template using the current API. - $update = $this->updateEmailTemplate('otpSession', 'en', 'Legacy OTP', 'Legacy OTP body'); - $this->assertSame(200, $update['headers']['status-code']); - - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - // Request filter should accept legacy `type` and map it to `templateId`. - $delete = $this->client->call( - Client::METHOD_DELETE, - '/project/templates/email', - $headers, - [ - 'type' => 'otpSession', - 'locale' => 'en', - ], - ); - - $this->assertSame(204, $delete['headers']['status-code']); - $this->assertEmpty($delete['body']); - - // Verify reset back to default. - $after = $this->getEmailTemplate('otpSession', 'en'); - $this->assertSame(200, $after['headers']['status-code']); - $this->assertFalse($after['body']['custom']); - $this->assertNotSame('Legacy OTP', $after['body']['subject']); - } - - public function testDeleteEmailTemplateLegacyInvalidType(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $delete = $this->client->call( - Client::METHOD_DELETE, - '/project/templates/email', - $headers, - [ - 'type' => 'notATemplate', - 'locale' => 'en', - ], - ); - - $this->assertSame(400, $delete['headers']['status-code']); - } - - public function testUpdateEmailTemplateLegacyInvalidType(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $update = $this->client->call( - Client::METHOD_PATCH, - '/project/templates/email', - $headers, - [ - 'type' => 'notATemplate', - 'locale' => 'en', - 'subject' => 'Subject', - 'message' => 'Message', - ], - ); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateLegacyReplyTo(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - // Legacy clients send replyTo (not replyToEmail) — request filter maps it. - $update = $this->client->call( - Client::METHOD_PATCH, - '/project/templates/email', - $headers, - [ - 'type' => 'invitation', - 'locale' => 'en', - 'subject' => 'Legacy reply-to subject', - 'message' => 'Legacy reply-to body', - 'senderName' => 'Legacy Sender', - 'senderEmail' => 'legacy-sender@appwrite.io', - 'replyTo' => 'legacy-reply@appwrite.io', - ], - ); - - $this->assertSame(200, $update['headers']['status-code']); - // Response filter should rename replyToEmail -> replyTo, strip replyToName / custom. - $this->assertArrayHasKey('replyTo', $update['body']); - $this->assertArrayNotHasKey('replyToEmail', $update['body']); - $this->assertArrayNotHasKey('replyToName', $update['body']); - $this->assertArrayNotHasKey('custom', $update['body']); - $this->assertSame('legacy-reply@appwrite.io', $update['body']['replyTo']); - $this->assertSame('Legacy Sender', $update['body']['senderName']); - $this->assertSame('legacy-sender@appwrite.io', $update['body']['senderEmail']); - - // Verify value is persisted and readable via the legacy GET shape. - $get = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/invitation', - $headers, - ['locale' => 'en'], - ); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertArrayHasKey('replyTo', $get['body']); - $this->assertArrayNotHasKey('replyToEmail', $get['body']); - $this->assertArrayNotHasKey('replyToName', $get['body']); - $this->assertArrayNotHasKey('custom', $get['body']); - $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); - - // Cleanup - $this->deleteEmailTemplate('invitation', 'en'); - } - - public function testGetEmailTemplateLegacyReplyTo(): void - { - // Seed a custom template using the current API (includes replyToEmail + replyToName). - $update = $this->updateEmailTemplate( - 'otpSession', - 'en', - 'Legacy OTP', - 'Legacy OTP body', - 'Legacy Sender', - 'legacy-sender@appwrite.io', - 'legacy-reply@appwrite.io', - 'Legacy Reply Team', - ); - $this->assertSame(200, $update['headers']['status-code']); - - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $get = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/otpSession', - $headers, - ['locale' => 'en'], - ); - - $this->assertSame(200, $get['headers']['status-code']); - // Legacy fields present - $this->assertArrayHasKey('type', $get['body']); - $this->assertArrayHasKey('replyTo', $get['body']); - $this->assertSame('otpSession', $get['body']['type']); - $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); - // New fields stripped - $this->assertArrayNotHasKey('templateId', $get['body']); - $this->assertArrayNotHasKey('replyToEmail', $get['body']); - $this->assertArrayNotHasKey('replyToName', $get['body']); - $this->assertArrayNotHasKey('custom', $get['body']); - - // Cleanup - $this->deleteEmailTemplate('otpSession', 'en'); - } - - // ========================================================================= - // Helpers - // ========================================================================= - - protected function getEmailTemplate(string $type, ?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/' . $type, $headers, $params); - } - - protected function updateEmailTemplate( - string $type, - ?string $locale, - ?string $subject, - ?string $message, - ?string $senderName = null, - ?string $senderEmail = null, - ?string $replyToEmail = null, - ?string $replyToName = null, - bool $authenticated = true, - ): mixed { - $params = [ - 'templateId' => $type, - ]; - - if ($locale !== null) { - $params['locale'] = $locale; - } - if ($subject !== null) { - $params['subject'] = $subject; - } - if ($message !== null) { - $params['message'] = $message; - } - if ($senderName !== null) { - $params['senderName'] = $senderName; - } - if ($senderEmail !== null) { - $params['senderEmail'] = $senderEmail; - } - if ($replyToEmail !== null) { - $params['replyToEmail'] = $replyToEmail; - } - if ($replyToName !== null) { - $params['replyToName'] = $replyToName; - } - - $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_PATCH, '/project/templates/email', $headers, $params); - } - - protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed - { - $params = [ - 'templateId' => $type, - ]; - - if ($locale !== null) { - $params['locale'] = $locale; - } - - $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_DELETE, '/project/templates/email', $headers, $params); - } } From 8bf2f54a511c75c2e19fb189462848f850c1a6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:04:46 +0200 Subject: [PATCH 062/123] Leftovers --- app/config/errors.php | 5 ----- src/Appwrite/Extend/Exception.php | 1 - 2 files changed, 6 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index 1d9d4593b9..4190c6e277 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,11 +1144,6 @@ return [ 'description' => 'Provided SMTP config is invalid. Please check the configured values and try again.', 'code' => 400, ], - Exception::PROJECT_TEMPLATE_CONFIG_INVALID => [ - 'name' => Exception::PROJECT_TEMPLATE_CONFIG_INVALID, - 'description' => 'Provided template config is invalid. Please check the configured values and try again.', - 'code' => 400, - ], Exception::PROJECT_TEMPLATE_DEFAULT_DELETION => [ 'name' => Exception::PROJECT_TEMPLATE_DEFAULT_DELETION, 'description' => 'You can\'t delete default template. If you are trying to reset your template changes, you can ignore this error as it\'s already been reset.', diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index b2eb06b752..58a21b5517 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -303,7 +303,6 @@ class Exception extends \Exception public const string ACCOUNT_KEY_EXPIRED = 'account_key_expired'; public const string PROJECT_SMTP_CONFIG_INVALID = 'project_smtp_config_invalid'; - public const string PROJECT_TEMPLATE_CONFIG_INVALID = 'project_template_config_invalid'; public const string PROJECT_TEMPLATE_DEFAULT_DELETION = 'project_template_default_deletion'; From e27719108b6cb6f1da58d82445ddd869a7ed624a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:15:03 +0200 Subject: [PATCH 063/123] Add tests --- tests/e2e/Services/Project/TemplatesBase.php | 731 +++++++++++++++++++ 1 file changed, 731 insertions(+) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b53db7b6eb..d6dbd0c6b6 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -6,4 +6,735 @@ use Tests\E2E\Client; trait TemplatesBase { + // Get email template tests + + public function testGetEmailTemplateDefault(): void + { + $response = $this->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. + $this->patchSMTP(['enabled' => false]); + + 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']); + } + + // 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->patchSMTP([ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ]); + } + + /** + * @param array $params + */ + protected function patchSMTP(array $params): void + { + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + $params, + ); + } + } From 9e94f15f024a47c71021b93fa0317d9069062fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:23:34 +0200 Subject: [PATCH 064/123] Finalize tests --- app/controllers/api/projects.php | 27 ++++ tests/e2e/Services/Project/TemplatesBase.php | 153 ++++++++++++++++--- 2 files changed, 161 insertions(+), 19 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 562ff15c39..78583d4748 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -647,3 +647,30 @@ Http::patch('/v1/projects/:projectId/auth/session-invalidation') $response->dynamic($project, Response::MODEL_PROJECT); }); + +// 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') + ->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') + ->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', []); + unset($templates['email.' . $type . '-' . $locale]); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); + + $response->noContent(); + }); diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index d6dbd0c6b6..efdc3e4579 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; trait TemplatesBase { @@ -549,7 +550,15 @@ trait TemplatesBase public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void { // Custom templates only make sense alongside a custom SMTP configuration. - $this->patchSMTP(['enabled' => false]); + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + ['enabled' => false], + ); try { $response = $this->updateEmailTemplate( @@ -656,6 +665,121 @@ trait TemplatesBase $this->assertSame(400, $response['headers']['status-code']); } + // Session alert integration + + public function testSessionAlertUsesCustomTemplatePerLocale(): void + { + $this->ensureSMTPEnabled(); + + // session-alerts lives under /projects (console scope), so it's driven with the + // root console session rather than the current test's project-scoped headers. + $alertsResponse = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], + ['alerts' => true], + ); + $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); + + $runId = \uniqid(); + $enSubject = "EN alert subject {$runId}"; + $enMessage = "EN alert body marker {$runId}"; + $skSubject = "SK alert subject {$runId}"; + $skMessage = "SK alert body marker {$runId}"; + + // Configure custom EN template via the default-locale path (omit `locale`). + $enUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: $enSubject, + message: $enMessage, + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + + // Configure custom SK template explicitly. + $skUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'sk', + subject: $skSubject, + message: $skMessage, + ); + $this->assertSame(200, $skUpdate['headers']['status-code']); + + // Matrix of request-time locales and the custom template each one must resolve to. + // `de` has no custom template stored, so it must fall back to the `en` custom template. + $cases = [ + ['requestLocale' => 'en', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => null, 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => 'sk', 'expectedSubject' => $skSubject, 'expectedMessageMarker' => $skMessage], + ['requestLocale' => 'de', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ]; + + foreach ($cases as $case) { + $localeLabel = $case['requestLocale'] ?? 'none'; + $email = "session-alert-{$runId}-{$localeLabel}@appwrite.io"; + $password = 'password123'; + + // Fresh user per case so the session count starts at zero. + $create = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session Alert ' . $localeLabel, + ]); + $this->assertSame(201, $create['headers']['status-code'], "create user ({$localeLabel})"); + + // First session must NOT trigger an alert (count === 1 returns early). + $first = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $first['headers']['status-code'], "first session ({$localeLabel})"); + + // Second session — this one triggers the alert, with the test's request locale. + $headers = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + if ($case['requestLocale'] !== null) { + $headers['x-appwrite-locale'] = $case['requestLocale']; + } + $second = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $headers, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $second['headers']['status-code'], "second session ({$localeLabel})"); + + // The custom subject is uniquely tagged per run, so matching it proves both + // that an alert was sent and that the correct locale template was resolved. + $received = $this->getLastEmailByAddress($email, function ($mail) use ($case) { + $this->assertSame($case['expectedSubject'], $mail['subject']); + }); + + $this->assertSame($case['expectedSubject'], $received['subject'], "subject ({$localeLabel})"); + $this->assertStringContainsString( + $case['expectedMessageMarker'], + $received['text'] . $received['html'], + "message marker ({$localeLabel})", + ); + } + } + // Helpers protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed @@ -709,22 +833,6 @@ trait TemplatesBase } protected function ensureSMTPEnabled(): void - { - $this->patchSMTP([ - 'enabled' => true, - 'senderName' => 'Mailer', - 'senderEmail' => 'mailer@appwrite.io', - 'host' => 'maildev', - 'port' => 1025, - 'username' => 'user', - 'password' => 'password', - ]); - } - - /** - * @param array $params - */ - protected function patchSMTP(array $params): void { $this->client->call( Client::METHOD_PATCH, @@ -733,8 +841,15 @@ trait TemplatesBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), - $params, + [ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], ); } - } From a4ad1b6df3d48256978eb6b46fb5d106b1c1718e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:35:35 +0200 Subject: [PATCH 065/123] Code quality improvs --- .../Project/Http/Project/Templates/Email/Update.php | 12 ++++++------ src/Appwrite/SDK/Specification/Format.php | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) 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 index 4a1d4a5a21..c4a96d3c3c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -38,7 +38,7 @@ class Update extends Action ->desc('Update project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') - ->label('event', 'templates.[templateType].update') + ->label('event', 'templates.[templateId].update') ->label('audits.event', 'project.template.update') ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( @@ -126,17 +126,17 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $templateId); + $queueForEvents->setParam('templateId', $templateId); $response->dynamic(new Document([ 'templateId' => $templateId, 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyToEmail' => $template['replyToEmail'], - 'replyToName' => $template['replyToName'], 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..1dad8764fa 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -763,7 +763,6 @@ abstract class Format switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': - case 'deleteEmailTemplate': switch ($param) { case 'type': return 'EmailTemplateType'; From 8c31e9f206390a46b05eadb35c42093f16fab512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:36:47 +0200 Subject: [PATCH 066/123] Server error fixes --- .../Platform/Modules/Project/Http/Project/Labels/Update.php | 2 +- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 304d9dc8a6..5eee625b0a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -32,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( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 63e9cf287e..820c0be287 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -40,7 +40,7 @@ class Update extends Action ->desc('Update project SMTP configuration') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') + ->label('event', 'project.smtp.update') ->label('audits.event', 'project.smtp.update') ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( From c3e411fcaa4db2f314606f79a3a33689ac02128d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:47:47 +0200 Subject: [PATCH 067/123] Fix tests --- .../Project/Http/Project/Labels/Update.php | 2 +- .../Modules/Project/Http/Project/SMTP/Update.php | 14 ++++++++------ .../Project/Http/Project/Templates/Email/Get.php | 6 +++--- .../Http/Project/Templates/Email/Update.php | 16 +++++++++------- tests/e2e/Services/Project/TemplatesBase.php | 5 ++++- 5 files changed, 25 insertions(+), 18 deletions(-) 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 5eee625b0a..8a3506eb13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.labels.update') + // ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 820c0be287..736865f299 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -40,7 +40,7 @@ class Update extends Action ->desc('Update project SMTP configuration') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.smtp.update') + // ->label('event', 'project.smtp.update') ->label('audits.event', 'project.smtp.update') ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( @@ -106,11 +106,13 @@ class Update extends Action // Backwards compatibility $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; - // Ensure required fields are set - $requiredKeys = ['host', 'port', 'senderEmail']; - foreach ($requiredKeys as $key) { - if (empty($smtp[$key])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, '"' . $key . '" is required. Please provide a value.'); + 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.'); + } } } 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 index 1843c556d4..02ba431775 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -78,16 +78,16 @@ class Get extends Action $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); // Apply defaults if needed - if (\is_null($template['message'])) { + if (\is_null($template['message'] ?? null)) { $template['message'] = $defaultMessage; } - if (\is_null($template['subject'])) { + if (\is_null($template['subject'] ?? null)) { $template['subject'] = $defaultSubject; } // Backwards compatibility - if (!\is_null($template['replyTo'])) { + if (!\is_null($template['replyTo'] ?? null)) { $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; } 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 index c4a96d3c3c..ef93abf683 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -58,12 +58,12 @@ class Update extends Action )) ->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.') - ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') - ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', true) - ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', true) - ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', true) - ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', true) + ->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') @@ -108,7 +108,9 @@ class Update extends Action } // Backwards compatibility - $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } // Ensure required fields are set $requiredKeys = ['subject', 'message']; diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index efdc3e4579..72a14210a5 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -550,7 +550,7 @@ trait TemplatesBase public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void { // Custom templates only make sense alongside a custom SMTP configuration. - $this->client->call( + $response = $this->client->call( Client::METHOD_PATCH, '/project/smtp', \array_merge([ @@ -560,6 +560,9 @@ trait TemplatesBase ['enabled' => false], ); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + try { $response = $this->updateEmailTemplate( templateId: 'verification', From 72cd7671c43381b6e82eb7163b4a493b556ece70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:01:00 +0200 Subject: [PATCH 068/123] Fix projects tests --- .../Projects/ProjectsConsoleClientTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 9506c1a963..471d737f76 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1131,6 +1131,40 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('', $response['body']['senderEmail']); $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([ From 4808cad08196b479b871f2cec45ec434d275290b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:01:53 +0200 Subject: [PATCH 069/123] Fix most of Project tests --- .../Project/Http/Project/SMTP/Update.php | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 736865f299..97e723f52c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -58,15 +58,15 @@ class Update extends Action ) ], )) - ->param('host', '', new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) - ->param('port', 587, new Nullable(new Integer()), 'SMTP server port', optional: true) - ->param('username', '', new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) - ->param('password', '', 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', '', new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) - ->param('senderName', '', new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) - ->param('replyToEmail', '', new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) - ->param('replyToName', '', new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) - ->param('secure', '', 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('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') @@ -117,7 +117,10 @@ class Update extends Action } // Validate SMTP credentials - if (\is_null($smtp['enabled'] ?? null) || $smtp['enabled'] === true) { + // 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(); From b1d37bc4bea36263f8f48ba1eaf639932d661377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:03:16 +0200 Subject: [PATCH 070/123] Fix remaining test failures --- tests/e2e/Services/Project/SMTPBase.php | 2 +- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index fa58891b08..c3a9058a32 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -313,7 +313,7 @@ trait SMTPBase $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', - host: 'not a valid host!@#', + host: 'https://myhost.com/v1', port: 1025, ); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 471d737f76..d51c4c1128 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1131,7 +1131,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('', $response['body']['senderEmail']); $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', @@ -1145,7 +1145,7 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(400, $response['headers']['status-code']); - + /** Configure custom SMTP pointing to maildev, so changing template is allowed */ $smtpHost = 'maildev'; $smtpPort = 1025; From abd6f0add2988621712eb2d226e51a811c3cf0ac Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 21 Apr 2026 10:52:21 +0530 Subject: [PATCH 071/123] updated --- app/realtime.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index b5d8859d7b..667b035b9b 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1166,6 +1166,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); + // Validate every payload before executing any removal so an invalid entry // later in the batch does not leave earlier entries half-applied on the server. $validatedIds = []; @@ -1189,6 +1191,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'removed' => $wasRemoved, ]; } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } $unsubscribeResponsePayload = json_encode([ 'type' => 'response', From 5df65d541766ab5455810876811d4c34348d78f7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 21 Apr 2026 10:58:34 +0530 Subject: [PATCH 072/123] updated --- app/realtime.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 667b035b9b..552823336f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -804,7 +804,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); - $register->get('telemetry.workerSubscriptionCounter')->add(1, $register->get('telemetry.workerAttributes')); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); From cce04b3bd1848cd454391c9e78ed5265a5f2dd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 10:28:11 +0200 Subject: [PATCH 073/123] Improve test coverage --- tests/e2e/Services/Project/SMTPBase.php | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c3a9058a32..4bdf073e19 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -531,19 +531,38 @@ trait SMTPBase $this->updateSMTP(enabled: false); } - public function testUpdateSMTPInvalidConnectionRefused(): void + 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([ From 4e4860e7f85b5a518c43f681096fd6244fa3d82c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:27:51 +0000 Subject: [PATCH 074/123] refactor: move afterQuery hook into base listDocuments action Moves the DB-duration measurement and afterQuery() hook from the tablesDB-specific Rows/XList into the shared Databases/Collections/Documents/XList base. Because TablesDB Rows and DocumentsDB Documents both extend the legacy listDocuments base, a single override now covers all three endpoints: legacy listDocuments, listDocumentsDBDocuments, and tablesDB listRows. TablesDB Rows drops the ~200-line action() duplicate and keeps only the path/params/SDK overrides it needs, plus the extra ->inject('utopia') so its injection chain matches the new base action signature. DocumentsDB Documents gets the same one-line inject addition. Net -165 lines of duplication removed. Behaviour is unchanged for CE (afterQuery() is a no-op); downstream distributions overriding afterQuery() now observe every list-documents / list-rows call site for free. --- .../Databases/Collections/Documents/XList.php | 53 ++++- .../Collections/Documents/XList.php | 1 + .../Http/TablesDB/Tables/Rows/XList.php | 205 ------------------ 3 files changed, 47 insertions(+), 212 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index aeee280615..d03f67e4c1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -22,6 +22,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -80,10 +81,11 @@ class XList extends Action ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void { $isAPIKey = $user->isApp($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); @@ -126,19 +128,29 @@ class XList extends Action $cursor->setValue($cursorDocument); } + $dbDurationMs = 0.0; + $measure = function (callable $fn) use (&$dbDurationMs) { + $start = \microtime(true); + try { + return $fn(); + } finally { + $dbDurationMs += (\microtime(true) - $start) * 1000; + } + }; + try { $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. $find = $hasSelects - ? fn () => $dbForDatabases->find($collectionTableId, $queries) - : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) + : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); - $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; + $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); + $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; } elseif ((int)$ttl > 0) { $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); $roles = $dbForProject->getAuthorization()->getRoles(); @@ -180,7 +192,7 @@ class XList extends Action if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { - $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); + $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); try { $dbForProject->getCache()->save($cacheKey, $total, $totalField); } catch (\Throwable) { @@ -193,7 +205,7 @@ class XList extends Action $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { $documents = $find(); - $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; } } catch (OrderException $e) { $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; @@ -229,5 +241,32 @@ class XList extends Action // rows or documents $this->getSDKGroup() => $documents, ]), $this->getResponseModel()); + + try { + $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); + } catch (\Throwable) { + // Observers must never break the response. + } + } + + /** + * Hook invoked after listDocuments/listRows completes the response. + * Under Swoole the client connection is already closed by $response->dynamic(), + * so observers here do not delay the client; under synchronous transports + * they run before bytes reach the client — keep work cheap regardless. + * + * Runs with the actual measured database duration (cache hits report + * near-zero, cache misses report only the DB portion). Intended for + * downstream distributions to override for slow-query logging or other + * observability. CE implementation is a no-op. + * + * The $utopia Http instance is passed so overrides can resolve additional + * resources via $utopia->getResource(...) without touching the inject chain. + * + * @param array $queries parsed Query objects + */ + protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void + { + // no-op in CE } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php index 9e0d0b10d9..51c0d67e8a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php @@ -63,6 +63,7 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 315e1c2e67..87e276719e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -2,27 +2,15 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows; -use Appwrite\Databases\TransactionState; -use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\XList as DocumentXList; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Usage\Context; -use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Order as OrderException; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Exception\Timeout; -use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; -use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -80,197 +68,4 @@ class XList extends DocumentXList ->inject('utopia') ->callback($this->action(...)); } - - /** - * List rows with actual database duration measurement and a post-query - * observability hook. Mirrors the parent listDocuments action body but - * wraps each DB call with a timer so subclasses can observe just the DB - * portion of the request via afterQuery(). - * - * @param array $queries - */ - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void - { - $isAPIKey = $user->isApp($authorization->getRoles()); - $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $dbForDatabases = $getDatabasesDB($database); - $cursor = Query::getCursorQueries($queries, false); - $cursor = \reset($cursor); - - if ($cursor !== false) { - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $documentId = $cursor->getValue(); - - $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); - - if ($cursorDocument->isEmpty()) { - $type = ucfirst($this->getContext()); - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $dbDurationMs = 0.0; - $measure = function (callable $fn) use (&$dbDurationMs) { - $start = \microtime(true); - try { - return $fn(); - } finally { - $dbDurationMs += (\microtime(true) - $start) * 1000; - } - }; - - try { - $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); - $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); - $find = $hasSelects - ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) - : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); - - if ($transactionId !== null) { - $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); - $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; - } elseif ((int)$ttl > 0) { - $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); - $roles = $dbForProject->getAuthorization()->getRoles(); - $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - - $documentsCacheHit = false; - try { - $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); - } catch (\Throwable) { - $cachedDocuments = null; - } - - if ($cachedDocuments !== null && - $cachedDocuments !== false && - \is_array($cachedDocuments)) { - $documents = \array_map(function ($doc) { - return new Document($doc); - }, $cachedDocuments); - $documentsCacheHit = true; - } else { - $documents = $find(); - - $documentsArray = \array_map(function ($doc) { - return $doc->getArrayCopy(); - }, $documents); - try { - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); - } catch (\Throwable) { - } - } - - if ($includeTotal) { - $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - try { - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); - } catch (\Throwable) { - $cachedTotal = null; - } - if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; - } else { - $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); - try { - $dbForProject->getCache()->save($cacheKey, $total, $totalField); - } catch (\Throwable) { - } - } - } else { - $total = 0; - } - - $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); - } else { - $documents = $find(); - $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; - } - } catch (OrderException $e) { - $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; - $attribute = $this->isCollectionsAPI() ? 'attribute' : 'column'; - $message = "The order $attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all $documents order $attribute values are non-null."; - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } catch (Timeout) { - throw new Exception(Exception::DATABASE_TIMEOUT); - } - - $operations = 0; - $collectionsCache = []; - foreach ($documents as $document) { - $this->processDocument( - database: $database, - collection: $collection, - document: $document, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization, - operations: $operations - ); - } - - $usage - ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); - - $response->dynamic(new Document([ - 'total' => $total, - $this->getSDKGroup() => $documents, - ]), $this->getResponseModel()); - - try { - $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); - } catch (\Throwable) { - // Observers must never break the response. - } - } - - /** - * Hook invoked after listRows completes the response. Under Swoole (the - * default transport) the client connection has already been closed by - * `$response->dynamic()`, so observers here do not delay the client. - * Under synchronous transports observers would run before the bytes - * reach the client — keep work here cheap regardless. - * - * Runs with the actual measured database duration (cache hits report - * near-zero). Intended to be overridden for observability (e.g., slow- - * query logging in downstream distributions). CE implementation is a - * no-op. - * - * The `$utopia` Http instance is passed so overrides can resolve - * additional resources (e.g., a downstream-specific logger) via - * `$utopia->getResource(...)` without needing to inject them here. - * - * @param array $queries parsed Query objects (pass directly to - * `Query::fingerprint()` if you need a - * shape hash) - */ - protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void - { - // no-op in CE - } } From b4f16522861bc3bb604064aa225dd1ea68d390b5 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:41:11 +0000 Subject: [PATCH 075/123] refactor: simplify afterQuery DB timing to single wall-clock bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-call $measure closure with a single $dbStart timestamp taken right before the fetch block and a single subtraction right after it. Drops 6 lines of HOF indirection plus the $measure variable, at the cost of including cache GET/SET time (~0.5–5ms) in measurements when ttl > 0. For slow-query logging at a 100ms+ threshold that noise is negligible, and the default ttl=0 path has no cache ops at all so the measurement is pure DB engine time. The bracket captures the cursor lookup, find/count, and transaction state calls — everything between "query parsed" and "fetch done", as intended. processDocument's post-fetch relationship work is still outside the bracket, matching the original design. --- .../Databases/Collections/Documents/XList.php | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index d03f67e4c1..15015c0fe0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -128,15 +128,7 @@ class XList extends Action $cursor->setValue($cursorDocument); } - $dbDurationMs = 0.0; - $measure = function (callable $fn) use (&$dbDurationMs) { - $start = \microtime(true); - try { - return $fn(); - } finally { - $dbDurationMs += (\microtime(true) - $start) * 1000; - } - }; + $dbStart = \microtime(true); try { $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); @@ -144,13 +136,13 @@ class XList extends Action // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. $find = $hasSelects - ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) - : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); + ? fn () => $dbForDatabases->find($collectionTableId, $queries) + : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); - $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; + $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); + $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; } elseif ((int)$ttl > 0) { $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); $roles = $dbForProject->getAuthorization()->getRoles(); @@ -192,7 +184,7 @@ class XList extends Action if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { - $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); + $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); try { $dbForProject->getCache()->save($cacheKey, $total, $totalField); } catch (\Throwable) { @@ -205,7 +197,7 @@ class XList extends Action $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { $documents = $find(); - $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; + $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } catch (OrderException $e) { $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; @@ -218,6 +210,8 @@ class XList extends Action throw new Exception(Exception::DATABASE_TIMEOUT); } + $dbDurationMs = (\microtime(true) - $dbStart) * 1000; + $operations = 0; $collectionsCache = []; foreach ($documents as $document) { From f465e2267a76fea8649bd31b6ef424992cd61f47 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:43:20 +0000 Subject: [PATCH 076/123] chore: tighten afterQuery docblock --- .../Databases/Collections/Documents/XList.php | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 15015c0fe0..102a6ae7c1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -244,23 +244,11 @@ class XList extends Action } /** - * Hook invoked after listDocuments/listRows completes the response. - * Under Swoole the client connection is already closed by $response->dynamic(), - * so observers here do not delay the client; under synchronous transports - * they run before bytes reach the client — keep work cheap regardless. + * Override to observe list DB duration. No-op in CE. * - * Runs with the actual measured database duration (cache hits report - * near-zero, cache misses report only the DB portion). Intended for - * downstream distributions to override for slow-query logging or other - * observability. CE implementation is a no-op. - * - * The $utopia Http instance is passed so overrides can resolve additional - * resources via $utopia->getResource(...) without touching the inject chain. - * - * @param array $queries parsed Query objects + * @param array $queries */ protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void { - // no-op in CE } } From 50bd2877f4e25bf99795151686bc4bfedf0241ce Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:46:21 +0000 Subject: [PATCH 077/123] chore: shorten afterQuery docblock --- .../Databases/Http/Databases/Collections/Documents/XList.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 102a6ae7c1..c1297b98a0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -244,7 +244,7 @@ class XList extends Action } /** - * Override to observe list DB duration. No-op in CE. + * After query hook. * * @param array $queries */ From 4317ee5617d477ef9c2a108102f8aac685fe4de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:11:42 +0200 Subject: [PATCH 078/123] Move some of auth settings to project policies --- app/controllers/api/projects.php | 265 ------------------ app/init/constants.php | 2 - .../Policies/PasswordDictionary/Update.php | 78 ++++++ .../Policies/PasswordHistory/Update.php | 84 ++++++ .../Policies/PasswordPersonalData/Update.php | 78 ++++++ .../Policies/SessionDuration/Update.php | 78 ++++++ .../Policies/SessionInvalidation/Update.php | 78 ++++++ .../Project/Policies/SessionLimit/Update.php | 84 ++++++ .../Project/Policies/UserLimit/Update.php | 84 ++++++ .../Modules/Projects/Http/Projects/Create.php | 2 +- 10 files changed, 565 insertions(+), 268 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..87552c6508 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -218,82 +218,6 @@ Http::patch('/v1/projects/:projectId/auth/memberships-privacy') $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']) @@ -335,158 +259,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']) @@ -1050,40 +822,3 @@ Http::delete('/v1/projects/:projectId/templates/email') '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); - }); diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..443aaaa680 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -24,8 +24,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 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..6218165daf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -0,0 +1,78 @@ +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', 'policies.password-dictionary.update') + ->label('audits.event', 'policies.password-dictionary.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') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['passwordDictionary'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $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/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php new file mode 100644 index 0000000000..437cc29b33 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -0,0 +1,84 @@ +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', 'policies.password-history.update') + ->label('audits.event', 'policies.password-history.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') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): 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)); + + $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..cc661366d7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -0,0 +1,78 @@ +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', 'policies.password-personal-data.update') + ->label('audits.event', 'policies.password-personal-data.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') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['personalDataCheck'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $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/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php new file mode 100644 index 0000000000..ad2540172c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -0,0 +1,78 @@ +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', 'policies.session-duration.update') + ->label('audits.event', 'policies.session-duration.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionDurationPolicy', + description: <<param('duration', null, new Range(60, 31536000), 'Maximum session length in seconds. Minium allowed value is 60 seconds, and maximum is 1 year, which is 31536000 seconds.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $duration, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['duration'] = $duration; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $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/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php new file mode 100644 index 0000000000..0963d7eb56 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -0,0 +1,78 @@ +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', 'policies.session-invalidation.update') + ->label('audits.event', 'policies.session-invalidation.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') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['invalidateSessions'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $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/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php new file mode 100644 index 0000000000..3860b7ccc4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -0,0 +1,84 @@ +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', 'policies.session-limit.update') + ->label('audits.event', 'policies.session-limit.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') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): 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)); + + $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..6b21a22138 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -0,0 +1,84 @@ +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', 'policies.user-limit.update') + ->label('audits.event', 'policies.user-limit.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') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): 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)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index c509a565cd..e190b5a719 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, From 2f272f0480611e9818db0ef96d8d815ed89008db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:27:08 +0200 Subject: [PATCH 079/123] Cleanup unused markdowns descriptions --- docs/references/account/create-2fa-challenge.md | 1 - docs/references/account/delete-session-current.md | 1 - docs/references/documentsdb/get-collection-logs.md | 1 - docs/references/documentsdb/get-document-logs.md | 1 - docs/references/documentsdb/list-attributes.md | 1 - docs/references/functions/create-build.md | 1 - docs/references/functions/create-deployment.md | 5 ----- docs/references/functions/create-execution.md | 1 - docs/references/functions/create-function.md | 1 - docs/references/functions/create-variable.md | 1 - docs/references/functions/delete-deployment.md | 1 - docs/references/functions/delete-execution.md | 1 - docs/references/functions/delete-function.md | 1 - docs/references/functions/delete-variable.md | 1 - docs/references/functions/get-deployment-download.md | 1 - docs/references/functions/get-deployment.md | 1 - docs/references/functions/get-execution.md | 1 - docs/references/functions/get-function-usage.md | 1 - docs/references/functions/get-function.md | 1 - docs/references/functions/get-functions-usage.md | 1 - docs/references/functions/get-template.md | 1 - docs/references/functions/get-variable.md | 1 - docs/references/functions/list-deployments.md | 1 - docs/references/functions/list-executions.md | 1 - docs/references/functions/list-functions.md | 1 - docs/references/functions/list-runtimes.md | 1 - docs/references/functions/list-specifications.md | 1 - docs/references/functions/list-templates.md | 1 - docs/references/functions/list-variables.md | 1 - docs/references/functions/update-deployment-build.md | 1 - docs/references/functions/update-function-deployment.md | 1 - docs/references/functions/update-function.md | 1 - docs/references/functions/update-variable.md | 1 - docs/references/health/get-queue-stats-usage-dump.md | 1 - docs/references/health/get-queue-tasks.md | 1 - docs/references/health/get-queue.md | 1 - docs/references/messaging/delete.md | 1 - docs/references/project/create-variable.md | 1 - docs/references/project/delete-variable.md | 1 - docs/references/project/get-variable.md | 1 - docs/references/project/list-variables.md | 1 - docs/references/project/update-variable.md | 1 - docs/references/projects/create-key.md | 1 - docs/references/projects/create-platform.md | 1 - docs/references/projects/create-webhook.md | 1 - docs/references/projects/delete-key.md | 1 - docs/references/projects/delete-platform.md | 1 - docs/references/projects/delete-webhook.md | 1 - docs/references/projects/get-key.md | 1 - docs/references/projects/get-platform.md | 1 - docs/references/projects/get-webhook.md | 1 - docs/references/projects/list-keys.md | 1 - docs/references/projects/list-platforms.md | 1 - docs/references/projects/list-webhooks.md | 1 - docs/references/projects/update-api-status-all.md | 1 - docs/references/projects/update-api-status.md | 1 - docs/references/projects/update-auth-duration.md | 1 - docs/references/projects/update-auth-limit.md | 1 - docs/references/projects/update-auth-password-dictionary.md | 1 - docs/references/projects/update-auth-password-history.md | 1 - docs/references/projects/update-auth-sessions-limit.md | 1 - docs/references/projects/update-key.md | 1 - docs/references/projects/update-memberships-privacy.md | 1 - docs/references/projects/update-personal-data-check.md | 1 - docs/references/projects/update-platform.md | 1 - docs/references/projects/update-service-status-all.md | 1 - docs/references/projects/update-service-status.md | 1 - docs/references/projects/update-session-invalidation.md | 1 - docs/references/projects/update-webhook-signature.md | 1 - docs/references/projects/update-webhook.md | 1 - docs/references/tablesdb/get-database.md | 1 - docs/references/vectorsdb/decrement-document-attribute.md | 1 - docs/references/vectorsdb/get-collection-logs.md | 1 - docs/references/vectorsdb/get-document-logs.md | 1 - docs/references/vectorsdb/increment-document-attribute.md | 1 - docs/references/vectorsdb/list-attributes.md | 1 - 76 files changed, 80 deletions(-) delete mode 100644 docs/references/account/create-2fa-challenge.md delete mode 100644 docs/references/account/delete-session-current.md delete mode 100644 docs/references/documentsdb/get-collection-logs.md delete mode 100644 docs/references/documentsdb/get-document-logs.md delete mode 100644 docs/references/documentsdb/list-attributes.md delete mode 100644 docs/references/functions/create-build.md delete mode 100644 docs/references/functions/create-deployment.md delete mode 100644 docs/references/functions/create-execution.md delete mode 100644 docs/references/functions/create-function.md delete mode 100644 docs/references/functions/create-variable.md delete mode 100644 docs/references/functions/delete-deployment.md delete mode 100644 docs/references/functions/delete-execution.md delete mode 100644 docs/references/functions/delete-function.md delete mode 100644 docs/references/functions/delete-variable.md delete mode 100644 docs/references/functions/get-deployment-download.md delete mode 100644 docs/references/functions/get-deployment.md delete mode 100644 docs/references/functions/get-execution.md delete mode 100644 docs/references/functions/get-function-usage.md delete mode 100644 docs/references/functions/get-function.md delete mode 100644 docs/references/functions/get-functions-usage.md delete mode 100644 docs/references/functions/get-template.md delete mode 100644 docs/references/functions/get-variable.md delete mode 100644 docs/references/functions/list-deployments.md delete mode 100644 docs/references/functions/list-executions.md delete mode 100644 docs/references/functions/list-functions.md delete mode 100644 docs/references/functions/list-runtimes.md delete mode 100644 docs/references/functions/list-specifications.md delete mode 100644 docs/references/functions/list-templates.md delete mode 100644 docs/references/functions/list-variables.md delete mode 100644 docs/references/functions/update-deployment-build.md delete mode 100644 docs/references/functions/update-function-deployment.md delete mode 100644 docs/references/functions/update-function.md delete mode 100644 docs/references/functions/update-variable.md delete mode 100644 docs/references/health/get-queue-stats-usage-dump.md delete mode 100644 docs/references/health/get-queue-tasks.md delete mode 100644 docs/references/health/get-queue.md delete mode 100644 docs/references/messaging/delete.md delete mode 100644 docs/references/project/create-variable.md delete mode 100644 docs/references/project/delete-variable.md delete mode 100644 docs/references/project/get-variable.md delete mode 100644 docs/references/project/list-variables.md delete mode 100644 docs/references/project/update-variable.md delete mode 100644 docs/references/projects/create-key.md delete mode 100644 docs/references/projects/create-platform.md delete mode 100644 docs/references/projects/create-webhook.md delete mode 100644 docs/references/projects/delete-key.md delete mode 100644 docs/references/projects/delete-platform.md delete mode 100644 docs/references/projects/delete-webhook.md delete mode 100644 docs/references/projects/get-key.md delete mode 100644 docs/references/projects/get-platform.md delete mode 100644 docs/references/projects/get-webhook.md delete mode 100644 docs/references/projects/list-keys.md delete mode 100644 docs/references/projects/list-platforms.md delete mode 100644 docs/references/projects/list-webhooks.md delete mode 100644 docs/references/projects/update-api-status-all.md delete mode 100644 docs/references/projects/update-api-status.md delete mode 100644 docs/references/projects/update-auth-duration.md delete mode 100644 docs/references/projects/update-auth-limit.md delete mode 100644 docs/references/projects/update-auth-password-dictionary.md delete mode 100644 docs/references/projects/update-auth-password-history.md delete mode 100644 docs/references/projects/update-auth-sessions-limit.md delete mode 100644 docs/references/projects/update-key.md delete mode 100644 docs/references/projects/update-memberships-privacy.md delete mode 100644 docs/references/projects/update-personal-data-check.md delete mode 100644 docs/references/projects/update-platform.md delete mode 100644 docs/references/projects/update-service-status-all.md delete mode 100644 docs/references/projects/update-service-status.md delete mode 100644 docs/references/projects/update-session-invalidation.md delete mode 100644 docs/references/projects/update-webhook-signature.md delete mode 100644 docs/references/projects/update-webhook.md delete mode 100644 docs/references/tablesdb/get-database.md delete mode 100644 docs/references/vectorsdb/decrement-document-attribute.md delete mode 100644 docs/references/vectorsdb/get-collection-logs.md delete mode 100644 docs/references/vectorsdb/get-document-logs.md delete mode 100644 docs/references/vectorsdb/increment-document-attribute.md delete mode 100644 docs/references/vectorsdb/list-attributes.md 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-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 From d0f853d4cd909a54b2b759853d9986e0dfba7443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:38:27 +0200 Subject: [PATCH 080/123] Add more project policies --- app/controllers/api/projects.php | 80 ---------------- .../projects/update-session-alerts.md | 1 - .../Policies/MembershipPrivacy/Update.php | 91 +++++++++++++++++++ .../Policies/PasswordPersonalData/Update.php | 1 + .../Project/Policies/SessionAlert/Update.php | 78 ++++++++++++++++ .../Modules/Projects/Http/Projects/Create.php | 2 + .../Modules/Teams/Http/Memberships/Get.php | 16 +++- .../Modules/Teams/Http/Memberships/XList.php | 16 +++- .../Utopia/Response/Model/Membership.php | 6 ++ .../Utopia/Response/Model/Project.php | 14 +++ 10 files changed, 218 insertions(+), 87 deletions(-) delete mode 100644 docs/references/projects/update-session-alerts.md create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 87552c6508..ea5e5754d6 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -138,86 +138,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/:method') ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') ->groups(['api', 'projects']) 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/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..2b0b0432cc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -0,0 +1,91 @@ +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', 'policies.membership-privacy.update') + ->label('audits.event', 'policies.membership-privacy.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.') + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.') + ->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.') + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.') + ->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.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $userId, + bool $userEmail, + bool $userPhone, + bool $userName, + bool $userMFA, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + $auths['membershipsUserName'] = $userName; + $auths['membershipsUserEmail'] = $userEmail; + $auths['membershipsMfa'] = $userMFA; + $auths['membershipsUserId'] = $userId; + $auths['membershipsUserPhone'] = $userPhone; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $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/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php index cc661366d7..4ba90045c8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -49,6 +49,7 @@ class Update extends Action ) ], )) + // TODO: Split into more toggles, simiplar to membership privacy policy ->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') 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..fe9a0dac0b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -0,0 +1,78 @@ +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', 'policies.session-alert.update') + ->label('audits.event', 'policies.session-alert.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') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['sessionAlerts'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index e190b5a719..363c99dc1f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -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/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index f3fd9a4bb9..9cd784e5d0 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,14 @@ class Get extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } + + 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..ca18ed4920 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,14 @@ class XList extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); return $membership; 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..ea219d6047 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', @@ -475,6 +487,8 @@ class Project extends Model $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? true); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? true); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { From 5f9dc0fcd8604c1195a77937c9b96f7f43e2244b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:58:36 +0200 Subject: [PATCH 081/123] Req & res filters, review fixes --- app/config/roles.php | 1 + app/config/scopes/project.php | 4 ++ app/controllers/general.php | 8 ++++ app/init/constants.php | 4 +- src/Appwrite/Migration/Migration.php | 1 + .../Policies/MembershipPrivacy/Update.php | 40 ++++++++++++------- .../Policies/PasswordHistory/Update.php | 4 +- .../Project/Policies/SessionLimit/Update.php | 4 +- .../Project/Policies/UserLimit/Update.php | 4 +- .../Modules/Project/Services/Http.php | 20 ++++++++++ src/Appwrite/Platform/Workers/Migrations.php | 1 + src/Appwrite/Utopia/Request/Filters/V23.php | 28 +++++++++++++ src/Appwrite/Utopia/Response/Filters/V23.php | 36 +++++++++++++++++ .../Utopia/Response/Model/Project.php | 10 ++--- tests/e2e/Scopes/ProjectCustom.php | 1 + 15 files changed, 138 insertions(+), 28 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V23.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V23.php diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..2eaa81ce49 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,7 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'policies.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..74c14ea933 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,8 @@ 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", + ], ]; diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..596fbd0926 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; @@ -897,6 +899,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', '')); @@ -921,6 +926,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/init/constants.php b/app/init/constants.php index 443aaaa680..8503032266 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -44,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/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/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index 2b0b0432cc..fcaf44aff4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -49,11 +49,11 @@ class Update extends Action ) ], )) - ->param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.') - ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.') - ->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.') - ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.') - ->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.') + ->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') @@ -62,11 +62,11 @@ class Update extends Action } public function action( - bool $userId, - bool $userEmail, - bool $userPhone, - bool $userName, - bool $userMFA, + ?bool $userId, + ?bool $userEmail, + ?bool $userPhone, + ?bool $userName, + ?bool $userMFA, Response $response, Database $dbForPlatform, Document $project, @@ -74,11 +74,21 @@ class Update extends Action ): void { $auths = $project->getAttribute('auths', []); - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $userMFA; - $auths['membershipsUserId'] = $userId; - $auths['membershipsUserPhone'] = $userPhone; + 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, 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 index 437cc29b33..012f3e11f0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->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.')) + ->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') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $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 index 3860b7ccc4..407e7e43a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->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.')) + ->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') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $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 index 6b21a22138..6b614fdedc 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->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.')) + ->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') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $project, diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..e70f495bb5 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,6 +22,15 @@ 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\Variables\Create as CreateVariable; @@ -73,5 +82,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/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 339084727d..e19d705553 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -392,6 +392,7 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', ] ]); diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..8e92f9c13a --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,28 @@ +parseUpdateMembershipPrivacyPolicy($content); + break; + } + + return $content; + } + + protected function parseUpdateMembershipPrivacyPolicy(array $content): array + { + $content['userId'] = false; + $content['userPhone'] = false; + + 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..ccceb13f44 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,36 @@ + $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)), + default => $content, + }; + } + + private function parseMembership(array $content): array + { + unset($content['userPhone']); + + return $content; + } + + private function parseProject(array $content): array + { + unset($content['authMembershipsUserId']); + unset($content['authMembershipsUserPhone']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index ea219d6047..75d92ac013 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -484,11 +484,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('authMembershipsUserId', $authValues['membershipsUserId'] ?? true); - $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? 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/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..d0b9ef4b4f 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,7 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', ], ]); From 6adabae62090acd41155f6330486ac4daee423d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 14:05:46 +0200 Subject: [PATCH 082/123] Add policy tests --- tests/e2e/Services/Project/PoliciesBase.php | 825 ++++++++++++++++++ .../Project/PoliciesConsoleClientTest.php | 14 + .../Project/PoliciesCustomServerTest.php | 14 + 3 files changed, 853 insertions(+) create mode 100644 tests/e2e/Services/Project/PoliciesBase.php create mode 100644 tests/e2e/Services/Project/PoliciesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/PoliciesCustomServerTest.php diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php new file mode 100644 index 0000000000..666ca55fd6 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -0,0 +1,825 @@ +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(60); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(60, $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(59); + + $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 testUpdateMembershipPrivacyPolicyMissingParam(): void + { + // Missing userMFA + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => 'not-a-boolean', + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $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 @@ + Date: Tue, 21 Apr 2026 18:03:18 +0530 Subject: [PATCH 083/123] Fix OpenAPI enum keys analysis --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index bcb5a5486c..66c2cd7c1c 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -771,7 +771,7 @@ class OpenAPI3 extends Format /// If the enum flag is Set, add the enum values to the body $body['content'][$consumes[0]]['schema']['properties'][$name]['enum'] = $node['schema']['enum']; $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-name'] = $node['schema']['x-enum-name'] ?? null; - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys'] ?? null; + $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys']; } if ($node['schema']['x-upload-id'] ?? false) { From 99d230c70d43c1baf427cf6f038b1c8ecc122090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 14:39:02 +0200 Subject: [PATCH 084/123] Integration tests --- .../Policies/PasswordHistory/Update.php | 2 + ...liciesMembershipPrivacyIntegrationTest.php | 176 ++++++++++++++++++ ...iciesPasswordDictionaryIntegrationTest.php | 68 +++++++ ...PoliciesPasswordHistoryIntegrationTest.php | 152 +++++++++++++++ .../PoliciesSessionLimitIntegrationTest.php | 121 ++++++++++++ .../PoliciesUserLimitIntegrationTest.php | 87 +++++++++ 6 files changed, 606 insertions(+) create mode 100644 tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php 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 index 012f3e11f0..a1aea6b0a4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -41,6 +41,8 @@ class Update extends Action name: 'updatePasswordHistoryPolicy', description: <<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/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php new file mode 100644 index 0000000000..66e45b06a9 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -0,0 +1,121 @@ +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(); + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + }, 15_000, 500); + + // Step 2: Session limit = 2 + $setSessionLimit(2); + + $session3 = $login(); + $this->assertEventually(function () use ($getAccount, $session2, $session3) { + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + }, 15_000, 500); + + // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain + $session4 = $login(); + $this->assertEventually(function () use ($getAccount, $session2, $session3, $session4) { + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Disable session limit, create 5 new sessions, all should remain usable + $setSessionLimit(null); + + $newSessions = []; + for ($i = 0; $i < 5; $i++) { + $newSessions[] = $login(); + } + + $this->assertEventually(function () use ($getAccount, $newSessions) { + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } + }, 15_000, 500); + } +} 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']); + } +} From eba145ee2d09175cfaa9075991208b05dc3a450b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 15:13:27 +0200 Subject: [PATCH 085/123] More integration tests --- .../Policies/SessionDuration/Update.php | 2 +- tests/e2e/Services/Project/PoliciesBase.php | 4 +- ...iesPasswordPersonalDataIntegrationTest.php | 104 +++++++++++++++ .../PoliciesSessionAlertIntegrationTest.php | 121 ++++++++++++++++++ ...PoliciesSessionDurationIntegrationTest.php | 102 +++++++++++++++ ...ciesSessionInvalidationIntegrationTest.php | 119 +++++++++++++++++ 6 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php 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 index ad2540172c..c58951213d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -49,7 +49,7 @@ class Update extends Action ) ], )) - ->param('duration', null, new Range(60, 31536000), 'Maximum session length in seconds. Minium allowed value is 60 seconds, and maximum is 1 year, which is 31536000 seconds.') + ->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') diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index 666ca55fd6..db3576a523 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -306,7 +306,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyMin(): void { - $response = $this->updateSessionDurationPolicy(60); + $response = $this->updateSessionDurationPolicy(1); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(60, $response['body']['authDuration']); @@ -325,7 +325,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyBelowMin(): void { - $response = $this->updateSessionDurationPolicy(59); + $response = $this->updateSessionDurationPolicy(0); $this->assertSame(400, $response['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..b58514a348 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -0,0 +1,102 @@ +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 be usable again + $setDuration(10); + + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 5: Poll until the 10s TTL elapses - session should expire again + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 20_000, 500); + + // Step 6: Set duration to 1 year + $setDuration(31536000); + + // Step 7: Same session should be usable again + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + } +} 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']); + } +} From 0be94a7aff270b3c9246a8a3d83a659fb7e74797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 15:57:30 +0200 Subject: [PATCH 086/123] Fix tests --- .../Policies/MembershipPrivacy/Update.php | 1 - .../Modules/Teams/Http/Memberships/Get.php | 2 + .../Modules/Teams/Http/Memberships/XList.php | 2 + tests/e2e/Services/Project/PoliciesBase.php | 105 ++++++++++++++++-- ...liciesMembershipPrivacyIntegrationTest.php | 2 +- 5 files changed, 99 insertions(+), 13 deletions(-) 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 index fcaf44aff4..c56e65308e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -95,7 +95,6 @@ class Update extends Action ]); $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index 9cd784e5d0..d146684a20 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -117,6 +117,8 @@ class Get extends Action if ($membershipsPrivacy['userId']) { $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); } if ($membershipsPrivacy['userPhone']) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index ca18ed4920..70b78e02c6 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -171,6 +171,8 @@ class XList extends Action if ($membershipsPrivacy['userId']) { $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); } if ($membershipsPrivacy['userPhone']) { diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index db3576a523..84f5938d3e 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -306,10 +306,10 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyMin(): void { - $response = $this->updateSessionDurationPolicy(1); + $response = $this->updateSessionDurationPolicy(5); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame(60, $response['body']['authDuration']); + $this->assertSame(5, $response['body']['authDuration']); // Cleanup $this->updateSessionDurationPolicy(31536000); @@ -325,7 +325,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyBelowMin(): void { - $response = $this->updateSessionDurationPolicy(0); + $response = $this->updateSessionDurationPolicy(4); $this->assertSame(400, $response['headers']['status-code']); } @@ -693,27 +693,110 @@ trait PoliciesBase ]); } - public function testUpdateMembershipPrivacyPolicyMissingParam(): void + public function testUpdateMembershipPrivacyPolicyIndividualFields(): void { - // Missing userMFA - $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + // Start from a known baseline where every field is enabled + $this->updateMembershipPrivacyPolicy([ 'userId' => true, 'userEmail' => true, 'userPhone' => true, 'userName' => true, + 'userMFA' => true, ]); - $this->assertSame(400, $response['headers']['status-code']); + $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', - 'userEmail' => true, - 'userPhone' => true, - 'userName' => true, - 'userMFA' => true, ]); $this->assertSame(400, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php index 378cd1800d..2acdb9715d 100644 --- a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php @@ -119,7 +119,7 @@ class PoliciesMembershipPrivacyIntegrationTest extends Scope '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']); From 648ffcdcfa9f7f7e1439e3f131d0be2a1e754c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:07:52 +0200 Subject: [PATCH 087/123] Fix event labels --- .../Project/Policies/MembershipPrivacy/Update.php | 12 ++++++++++-- .../Project/Policies/PasswordDictionary/Update.php | 11 +++++++++-- .../Http/Project/Policies/PasswordHistory/Update.php | 11 +++++++++-- .../Project/Policies/PasswordPersonalData/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionAlert/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionDuration/Update.php | 11 +++++++++-- .../Project/Policies/SessionInvalidation/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionLimit/Update.php | 11 +++++++++-- .../Http/Project/Policies/UserLimit/Update.php | 11 +++++++++-- 9 files changed, 82 insertions(+), 18 deletions(-) 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 index c56e65308e..c947ff225a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update membership privacy policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.membership-privacy.update') - ->label('audits.event', 'policies.membership-privacy.update') + ->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', @@ -58,6 +59,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -71,6 +73,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -95,6 +98,11 @@ class Update extends Action ]); $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 index 6218165daf..e2c678abb6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update password dictionary policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-dictionary.update') - ->label('audits.event', 'policies.password-dictionary.update') + ->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', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['passwordDictionary'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $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 index a1aea6b0a4..a8ae81caff 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update password history policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-history.update') - ->label('audits.event', 'policies.password-history.update') + ->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', @@ -57,6 +58,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,6 +68,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -81,6 +84,10 @@ class Update extends Action $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 index 4ba90045c8..9db7cf0549 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update password personal data policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-personal-data.update') - ->label('audits.event', 'policies.password-personal-data.update') + ->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', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['personalDataCheck'] = $enabled; @@ -74,6 +77,10 @@ class Update extends Action $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 index fe9a0dac0b..22b7a44b04 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session alert policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-alert.update') - ->label('audits.event', 'policies.session-alert.update') + ->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', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['sessionAlerts'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $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 index c58951213d..ba72c93a6f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session duration policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-duration.update') - ->label('audits.event', 'policies.session-duration.update') + ->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', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['duration'] = $duration; @@ -73,6 +76,10 @@ class Update extends Action $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 index 0963d7eb56..8f8a959959 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session invalidation policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-invalidation.update') - ->label('audits.event', 'policies.session-invalidation.update') + ->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', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['invalidateSessions'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $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 index 407e7e43a6..382ed6f0d9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update session limit policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-limit.update') - ->label('audits.event', 'policies.session-limit.update') + ->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', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -79,6 +82,10 @@ class Update extends Action $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 index 6b614fdedc..9129b81250 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update user limit policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.user-limit.update') - ->label('audits.event', 'policies.user-limit.update') + ->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', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -79,6 +82,10 @@ class Update extends Action $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); } } From 6c89a05a608d13fcc87626abc03682e8593b8f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:38:53 +0200 Subject: [PATCH 088/123] Fix 0 session to mean unlimited --- app/controllers/shared/api.php | 5 +++++ .../Project/PoliciesMembershipPrivacyIntegrationTest.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bba00bede1..6519e1f28f 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -758,6 +758,11 @@ Http::shutdown() ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + + if ($sessionLimit === 0) { + return; + } + $session = $response->getPayload(); $userId = $session['userId'] ?? ''; if (empty($userId)) { diff --git a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php index 2acdb9715d..378cd1800d 100644 --- a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php @@ -119,7 +119,7 @@ class PoliciesMembershipPrivacyIntegrationTest extends Scope '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']); From 06eb550e9893d740a38a4eb0c752a03e0a818ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:56:00 +0200 Subject: [PATCH 089/123] Finalize tests --- .github/workflows/ci.yml | 2 -- app/controllers/api/account.php | 2 +- ...PoliciesSessionDurationIntegrationTest.php | 24 ++++++------------- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02d021f1a..ac30bd64ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,9 +692,7 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml curl https://appwrite.io/install/env -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..761ea33806 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3274,7 +3274,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) { diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php index b58514a348..71562f52a5 100644 --- a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -76,27 +76,17 @@ class PoliciesSessionDurationIntegrationTest extends Scope $this->assertSame(401, $response['headers']['status-code']); }, 15_000, 500); - // Step 4: Raise duration to 10s - same session should be usable again + // Step 4: Raise duration to 10s - same session should still not be usable $setDuration(10); - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(200, $response['headers']['status-code']); - }, 15_000, 500); + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); - // Step 5: Poll until the 10s TTL elapses - session should expire again - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(401, $response['headers']['status-code']); - }, 20_000, 500); - - // Step 6: Set duration to 1 year + // Step 5: Set duration to 1 year $setDuration(31536000); - // Step 7: Same session should be usable again - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(200, $response['headers']['status-code']); - }, 15_000, 500); + // 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']); } } From c8a1746119341c72aba116fc0c557169f85253f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:34:44 +0200 Subject: [PATCH 090/123] Fix existing tests --- src/Appwrite/Utopia/Request/Filters/V23.php | 33 +++++++++++++++++ .../Account/AccountCustomClientTest.php | 1 + .../PoliciesSessionLimitIntegrationTest.php | 37 ++++++++++--------- tests/e2e/Services/Projects/ProjectsBase.php | 1 + .../Projects/ProjectsConsoleClientTest.php | 19 ++++++++++ tests/e2e/Services/Teams/TeamsBaseClient.php | 2 +- 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index 8e92f9c13a..acd238a0fe 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -13,6 +13,14 @@ class V23 extends Filter 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; @@ -23,6 +31,31 @@ class V23 extends Filter $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']; + unset($content['limit']); + } + return $content; } } diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 49f0c4c245..1ad42750e7 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/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php index 66e45b06a9..df8a22a151 100644 --- a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -82,27 +82,30 @@ class PoliciesSessionLimitIntegrationTest extends Scope // New session pushes old one out $session2 = $login(); - $this->assertEventually(function () use ($getAccount, $session1, $session2) { - $this->assertSame(200, $getAccount($session2)['headers']['status-code']); - $this->assertSame(401, $getAccount($session1)['headers']['status-code']); - }, 15_000, 500); + + \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(); - $this->assertEventually(function () use ($getAccount, $session2, $session3) { - $this->assertSame(200, $getAccount($session2)['headers']['status-code']); - $this->assertSame(200, $getAccount($session3)['headers']['status-code']); - }, 15_000, 500); + + \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(); - $this->assertEventually(function () use ($getAccount, $session2, $session3, $session4) { - $this->assertSame(200, $getAccount($session4)['headers']['status-code']); - $this->assertSame(200, $getAccount($session3)['headers']['status-code']); - $this->assertSame(401, $getAccount($session2)['headers']['status-code']); - }, 15_000, 500); + + \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); @@ -112,10 +115,8 @@ class PoliciesSessionLimitIntegrationTest extends Scope $newSessions[] = $login(); } - $this->assertEventually(function () use ($getAccount, $newSessions) { - foreach ($newSessions as $index => $sessionCookie) { - $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); - } - }, 15_000, 500); + 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/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 122104e3d9..ef83e65d95 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -331,6 +331,7 @@ trait ProjectsBase $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, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..df49ef5993 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1255,6 +1255,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'alerts' => true, ]); @@ -1397,6 +1398,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 10, // Set session duration to 10 seconds ]); @@ -1464,6 +1466,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 600, // seconds ]); @@ -1484,6 +1487,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, ]); @@ -1540,6 +1544,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -1556,6 +1561,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -1874,6 +1880,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -1952,6 +1959,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, ]); @@ -1989,6 +1997,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' => 0, ]); @@ -2001,6 +2010,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' => 1, ]); @@ -2072,6 +2082,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, ]); @@ -2090,6 +2101,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' => 25, ]); @@ -2103,6 +2115,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' => 1, ]); @@ -2176,6 +2189,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2436,6 +2450,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2493,6 +2508,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2506,6 +2522,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -2525,6 +2542,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2637,6 +2655,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..397bc42c3b 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($response['body']['userId']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); From 70cb5ca68a665a3133c8a284c1fc7e8f5ce8f2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:34:59 +0200 Subject: [PATCH 091/123] linter fix --- .../Project/PoliciesSessionLimitIntegrationTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php index df8a22a151..295418a974 100644 --- a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -82,9 +82,9 @@ class PoliciesSessionLimitIntegrationTest extends Scope // 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']); @@ -92,17 +92,17 @@ class PoliciesSessionLimitIntegrationTest extends Scope $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']); From 193891a7c94d22ceb7e91c5a70a3fd20d3831473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:38:32 +0200 Subject: [PATCH 092/123] Fix analyzer --- tests/e2e/Services/Teams/TeamsBaseClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 397bc42c3b..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->assertArrayHasKey($response['body']['userId']); + $this->assertArrayHasKey('userId', $response['body']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); From d9d5a8133753bc34d116684ae410dfeb8b40cda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:51:31 +0200 Subject: [PATCH 093/123] Fix 0->null backwrds compatibility --- src/Appwrite/Utopia/Request/Filters/V23.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index acd238a0fe..a8a52a7ea0 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -52,7 +52,7 @@ class V23 extends Filter protected function parseLimitToTotal(array $content): array { if (isset($content['limit'])) { - $content['total'] = $content['limit']; + $content['total'] = $content['limit'] === 0 ? null : $content['limit']; unset($content['limit']); } From 7e963e84394a878397a5f662535e0b8b82a86d1f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:55:56 +0100 Subject: [PATCH 094/123] fix: honor _APP_WORKERS_NUM in realtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realtime was ignoring _APP_WORKERS_NUM and always computing workers as CPU × _APP_WORKER_PER_CORE, making it impossible to cap the worker count without also changing the per-core multiplier. Prefer _APP_WORKERS_NUM when set, falling back to the CPU × per-core calculation. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/realtime.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 552823336f..3461ca83e5 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -262,7 +262,9 @@ $stats->create(); $containerId = uniqid(); $statsDocument = null; -$workerNumber = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); + +$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) + ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); $adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80)); $adapter From 39af9c544d21bc4e54ab0b67ba98e9e691c50513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 18:22:44 +0200 Subject: [PATCH 095/123] Remove unnessessary backwards compatibility --- .../Projects/ProjectsConsoleClientTest.php | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index df49ef5993..ff18eab938 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1991,19 +1991,6 @@ 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'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - /** * Test for SUCCESS */ @@ -2095,20 +2082,6 @@ 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'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()), [ - 'limit' => 25, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** * Test for Success */ From c973ca0a5d91eba9366a5abead65846fd0bf9771 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 09:34:03 +0530 Subject: [PATCH 096/123] Address PHPStan level 4 review feedback --- src/Appwrite/Messaging/Adapter/Realtime.php | 9 +- src/Appwrite/Platform/Tasks/ScheduleBase.php | 4 +- src/Appwrite/SDK/Specification/Format.php | 49 +++++--- .../Database/Validator/Queries/Webhooks.php | 11 +- tests/e2e/Scopes/ApiVectorsDB.php | 110 ++++++++++++++++++ .../Databases/VectorsDBCustomClientTest.php | 2 + 6 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/Scopes/ApiVectorsDB.php diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 345481e0de..8fe7342ec2 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -452,6 +452,7 @@ class Realtime extends MessagingAdapter * Reserved channel params with expected type * If matched the expected type then skip the query parsing like in project */ + /** @var array $reservedParamExpectedTypes */ $reservedParamExpectedTypes = [ 'project' => 'string', ]; @@ -461,8 +462,14 @@ class Realtime extends MessagingAdapter $params = $getQueryParam($paramKey); if (\array_key_exists($paramKey, $reservedParamExpectedTypes) && $params !== null) { + $expectedType = $reservedParamExpectedTypes[$paramKey]; + $isExpectedType = match ($expectedType) { + 'array' => \is_array($params), + 'string' => \is_string($params), + }; + // If the value matches the expected type dont use it the queries - if (\is_string($params)) { + if ($isExpectedType) { $params = null; } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index 9ecd151474..1213f78924 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -73,7 +73,7 @@ abstract class ScheduleBase extends Action * 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute * 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker. */ - public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void + public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): never { Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1'); Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started'); @@ -102,7 +102,7 @@ abstract class ScheduleBase extends Action $this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate, $isResourceBlocked); }); - for (;;) { + while (true) { try { go(fn () => $this->enqueueResources($dbForPlatform, $getProjectDB)); $this->scheduleTelemetryCount->record(count($this->schedules), ['resourceType' => static::getSupportedResource()]); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 4b9ee63205..08f960b2a7 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -40,6 +40,9 @@ abstract class Format 'license.url' => '', ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array OAUTH_PROVIDER_BLACKLIST = [ [ 'namespace' => 'account', @@ -67,6 +70,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array PROVIDER_USAGE_BLACKLIST = [ [ 'namespace' => 'users', @@ -78,6 +84,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, required?: bool, nullable?: bool}> + */ private const array REQUEST_PARAMETER_OVERRIDES = [ [ 'namespace' => 'project', @@ -109,25 +118,20 @@ abstract class Format { $blacklist = []; - foreach (self::OAUTH_PROVIDER_BLACKLIST as $config) { + foreach ([...self::OAUTH_PROVIDER_BLACKLIST, ...self::PROVIDER_USAGE_BLACKLIST] as $config) { foreach ($config['methods'] as $method) { - $blacklist[] = [ + $entry = [ 'namespace' => $config['namespace'], 'method' => $method, 'parameter' => $config['parameter'], - 'excludeKeys' => $config['excludeKeys'], - ]; - } - } - - foreach (self::PROVIDER_USAGE_BLACKLIST as $config) { - foreach ($config['methods'] as $method) { - $blacklist[] = [ - 'namespace' => $config['namespace'], - 'method' => $method, - 'parameter' => $config['parameter'], - 'exclude' => $config['exclude'], ]; + if (isset($config['excludeKeys'])) { + $entry['excludeKeys'] = $config['excludeKeys']; + } + if (isset($config['exclude'])) { + $entry['exclude'] = $config['exclude']; + } + $blacklist[] = $entry; } } @@ -947,7 +951,7 @@ abstract class Format 'nullable' => $nullable, ]; - foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + foreach ($this->getRequestParameterOverrides() as $override) { if ( $override['namespace'] !== $service || !\in_array($method, $override['methods'], true) @@ -956,7 +960,12 @@ abstract class Format continue; } - $config['required'] = $override['required']; + if (isset($override['required'])) { + $config['required'] = $override['required']; + } + if (isset($override['nullable'])) { + $config['nullable'] = $override['nullable']; + } break; } @@ -965,6 +974,14 @@ abstract class Format return $config; } + /** + * @return list, parameter: string, required?: bool, nullable?: bool}> + */ + private function getRequestParameterOverrides(): array + { + return self::REQUEST_PARAMETER_OVERRIDES; + } + public function getResponseEnumName(string $model, string $param): ?string { if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index 9fbc158ab2..587ad58ea4 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -51,6 +51,15 @@ class Webhooks extends Base */ public function isValid($value): bool { + return parent::isValid($this->normalizeAliases($value)); + } + + private function normalizeAliases(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; + } + foreach ($value as &$queryString) { if (!\is_string($queryString)) { continue; @@ -61,6 +70,6 @@ class Webhooks extends Base } unset($queryString); - return parent::isValid($value); + return $value; } } diff --git a/tests/e2e/Scopes/ApiVectorsDB.php b/tests/e2e/Scopes/ApiVectorsDB.php new file mode 100644 index 0000000000..09494d3c10 --- /dev/null +++ b/tests/e2e/Scopes/ApiVectorsDB.php @@ -0,0 +1,110 @@ + Date: Wed, 22 Apr 2026 16:14:30 +1200 Subject: [PATCH 097/123] fix: cast cached total to int in listDocuments/listRows Redis stringifies scalars on save, so on a cache hit the `total` field was served as a string. Flutter SDK (and any strictly-typed client) then failed with `TypeError: "37": type 'String' is not a subtype of type 'int'`. The cache-miss path returned an int from `count()`, which is why only repeat requests with `ttl > 0` tripped the bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Databases/Http/Databases/Collections/Documents/XList.php | 2 +- tests/e2e/Services/Databases/DatabasesBase.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index c1297b98a0..d2f1915a4f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -182,7 +182,7 @@ class XList extends Action $cachedTotal = null; } if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; + $total = (int) $cachedTotal; } else { $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); try { diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index f5f1d1864c..b8f2a1742e 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3368,7 +3368,7 @@ trait DatabasesBase ]); $this->assertEquals(200, $documents2['headers']['status-code']); - $this->assertEquals(3, $documents2['body']['total']); + $this->assertSame(3, $documents2['body']['total']); $this->assertCount(3, $documents2['body'][$this->getRecordResource()]); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']); From e530bf41f7033fe341689d425334c00191864de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 09:59:00 +0200 Subject: [PATCH 098/123] Post-merge fix --- app/controllers/api/projects.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 84f109b975..bd5d0504cf 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -301,3 +301,30 @@ Http::post('/v1/projects/:projectId/jwts') 'scopes' => $scopes ])]), Response::MODEL_JWT); }); + +// 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') + ->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') + ->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', []); + unset($templates['email.' . $type . '-' . $locale]); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); + + $response->noContent(); + }); From bfa1960d8aa94c63d702d5e36221fd4251c843eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 10:00:10 +0200 Subject: [PATCH 099/123] Remove unneeded const --- app/init/constants.php | 1 - src/Appwrite/Migration/Version/V17.php | 2 +- src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index ce1b34f574..8eacf2fe12 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -25,7 +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_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 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/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index a599d08a04..97b58d8a51 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -482,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); From 72bb6378c23f009a045f5d6a83a75063cc777172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 10:00:19 +0200 Subject: [PATCH 100/123] Leftover --- app/controllers/shared/api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 74a05ce4e4..8b8c7ee066 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -753,7 +753,7 @@ 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; From affd5876abf11fcc88208f05e5a2cf84d97c5308 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 14:49:35 +0530 Subject: [PATCH 101/123] Add spec enum service overlap validation --- src/Appwrite/Platform/Tasks/Specs.php | 141 ++++++++++++++++++++++++ tests/unit/Platform/Tasks/SpecsTest.php | 122 ++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/unit/Platform/Tasks/SpecsTest.php diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 2c03ad3108..e9fa15e6c7 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -297,6 +297,146 @@ 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[] = "{$enum} (service '{$service}', 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): void + { + if (isset($node['enum']) && \is_array($node['enum'])) { + $enumName = $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + 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); + } + } + + $explicitEnumName = $this->getExplicitSpecEnumName($node); + if (!\is_null($explicitEnumName)) { + $this->addSpecEnumName($enums, $explicitEnumName); + } + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + $this->collectSpecEnumNames( + $value, + $enums, + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName) + ); + } + } + + 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', 'parameters', 'paths', 'properties', '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 +623,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/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php new file mode 100644 index 0000000000..a10c66bf81 --- /dev/null +++ b/tests/unit/Platform/Tasks/SpecsTest.php @@ -0,0 +1,122 @@ +verifyParsedSpec($spec); + } +} + +class SpecsTest extends TestCase +{ + private TestSpecs $specs; + + protected function setUp(): void + { + $this->specs = new TestSpecs(); + } + + public function testVerifyParsedSpecFailsOnServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/account/sessions/oauth2/{provider}' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'provider', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'Locale', + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecFailsOnDerivedServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'payload', + 'in' => 'body', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'locale' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => null, + ], + ], + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecAllowsDistinctServiceAndEnumNames(): void + { + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'locale', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'EmailTemplateLocale', + ], + ], + ], + ], + ], + ], + ]); + + $this->addToAssertionCount(1); + } +} From 4f74394e8fb24eee10ebeedadfa6f58b661967aa Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:21:41 +0530 Subject: [PATCH 102/123] Fix spec enum name validation --- src/Appwrite/Platform/Tasks/Specs.php | 6 +- src/Appwrite/SDK/Specification/Format.php | 9 +++ tests/unit/Platform/Tasks/SpecsTest.php | 67 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index e9fa15e6c7..31fb69460f 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -370,12 +370,12 @@ class Specs extends Action } $explicitEnumName = $this->getExplicitSpecEnumName($node); - if (!\is_null($explicitEnumName)) { + if (!\is_null($explicitEnumName) && !isset($node['enum'])) { $this->addSpecEnumName($enums, $explicitEnumName); } foreach ($node as $key => $value) { - if (!\is_array($value)) { + if ($key === 'items' || !\is_array($value)) { continue; } @@ -420,7 +420,7 @@ class Specs extends Action return $this->getFallbackSpecEnumName($parent, $fallbackName); } - if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'parameters', 'paths', 'properties', 'responses'], true)) { + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) { return $key; } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..0c28384db1 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -751,6 +751,15 @@ abstract class Format break; case 'project': switch ($method) { + case 'getEmailTemplate': + case 'updateEmailTemplate': + switch ($param) { + case 'templateId': + return 'EmailTemplateType'; + case 'locale': + return 'EmailTemplateLocale'; + } + break; case 'getUsage': switch ($param) { case 'period': diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php index a10c66bf81..6cb11b310e 100644 --- a/tests/unit/Platform/Tasks/SpecsTest.php +++ b/tests/unit/Platform/Tasks/SpecsTest.php @@ -11,6 +11,18 @@ class TestSpecs extends Specs { $this->verifyParsedSpec($spec); } + + public function collectEnums(array $spec): array + { + $collect = \Closure::bind(function (array $spec): array { + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + return $enums; + }, $this, Specs::class); + + return $collect($spec); + } } class SpecsTest extends TestCase @@ -119,4 +131,59 @@ class SpecsTest extends TestCase $this->addToAssertionCount(1); } + + public function testCollectSpecEnumNamesDoesNotDoubleRegisterExplicitNames(): void + { + $enums = $this->specs->collectEnums([ + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'Locale', + ], + ]); + + $this->assertSame(['Locale'], $enums['locale']); + } + + public function testCollectSpecEnumNamesDoesNotDoubleRegisterItemsEnums(): void + { + $enums = $this->specs->collectEnums([ + 'name' => 'Locale', + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['en'], + ], + ]); + + $this->assertSame(['Locale'], $enums['locale']); + } + + public function testVerifyParsedSpecIgnoresHttpMethodFallbackNames(): void + { + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'patch', + 'description' => 'Patch APIs', + ], + ], + 'paths' => [ + '/example' => [ + 'patch' => [ + 'parameters' => [ + [ + 'schema' => [ + 'type' => 'string', + 'enum' => ['enabled'], + ], + ], + ], + ], + ], + ], + ]); + + $this->addToAssertionCount(1); + } } From 2390d40731dd54ff517e34d51f01065aca739ddc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:24:13 +0530 Subject: [PATCH 103/123] Remove specs task unit test --- tests/unit/Platform/Tasks/SpecsTest.php | 189 ------------------------ 1 file changed, 189 deletions(-) delete mode 100644 tests/unit/Platform/Tasks/SpecsTest.php diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php deleted file mode 100644 index 6cb11b310e..0000000000 --- a/tests/unit/Platform/Tasks/SpecsTest.php +++ /dev/null @@ -1,189 +0,0 @@ -verifyParsedSpec($spec); - } - - public function collectEnums(array $spec): array - { - $collect = \Closure::bind(function (array $spec): array { - $enums = []; - $this->collectSpecEnumNames($spec, $enums); - - return $enums; - }, $this, Specs::class); - - return $collect($spec); - } -} - -class SpecsTest extends TestCase -{ - private TestSpecs $specs; - - protected function setUp(): void - { - $this->specs = new TestSpecs(); - } - - public function testVerifyParsedSpecFailsOnServiceEnumNameOverlap(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); - - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/account/sessions/oauth2/{provider}' => [ - 'get' => [ - 'parameters' => [ - [ - 'name' => 'provider', - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'Locale', - ], - ], - ], - ], - ], - ], - ]); - } - - public function testVerifyParsedSpecFailsOnDerivedServiceEnumNameOverlap(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); - - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/projects/{projectId}/templates/email/{type}/{locale}' => [ - 'patch' => [ - 'parameters' => [ - [ - 'name' => 'payload', - 'in' => 'body', - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'locale' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => null, - ], - ], - ], - ], - ], - ], - ], - ], - ]); - } - - public function testVerifyParsedSpecAllowsDistinctServiceAndEnumNames(): void - { - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/projects/{projectId}/templates/email/{type}/{locale}' => [ - 'patch' => [ - 'parameters' => [ - [ - 'name' => 'locale', - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'EmailTemplateLocale', - ], - ], - ], - ], - ], - ], - ]); - - $this->addToAssertionCount(1); - } - - public function testCollectSpecEnumNamesDoesNotDoubleRegisterExplicitNames(): void - { - $enums = $this->specs->collectEnums([ - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'Locale', - ], - ]); - - $this->assertSame(['Locale'], $enums['locale']); - } - - public function testCollectSpecEnumNamesDoesNotDoubleRegisterItemsEnums(): void - { - $enums = $this->specs->collectEnums([ - 'name' => 'Locale', - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'enum' => ['en'], - ], - ]); - - $this->assertSame(['Locale'], $enums['locale']); - } - - public function testVerifyParsedSpecIgnoresHttpMethodFallbackNames(): void - { - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'patch', - 'description' => 'Patch APIs', - ], - ], - 'paths' => [ - '/example' => [ - 'patch' => [ - 'parameters' => [ - [ - 'schema' => [ - 'type' => 'string', - 'enum' => ['enabled'], - ], - ], - ], - ], - ], - ], - ]); - - $this->addToAssertionCount(1); - } -} From 038be90969e01a75aa8e83fc9e04fc0c786c47cf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:33:18 +0530 Subject: [PATCH 104/123] Preserve nested items enum validation --- src/Appwrite/Platform/Tasks/Specs.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 31fb69460f..ce1d78799d 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -343,9 +343,9 @@ class Specs extends Action } } - private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null): void + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void { - if (isset($node['enum']) && \is_array($node['enum'])) { + if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) { $enumName = $this->getExplicitSpecEnumName($node) ?? $this->getFallbackSpecEnumName($node, $fallbackName); @@ -354,6 +354,7 @@ class Specs extends Action } } + $itemsEnumHandled = false; if ( isset($node['items']) && \is_array($node['items']) @@ -367,6 +368,8 @@ class Specs extends Action if (!\is_null($enumName)) { $this->addSpecEnumName($enums, $enumName); } + + $itemsEnumHandled = true; } $explicitEnumName = $this->getExplicitSpecEnumName($node); @@ -375,14 +378,15 @@ class Specs extends Action } foreach ($node as $key => $value) { - if ($key === 'items' || !\is_array($value)) { + if (!\is_array($value)) { continue; } $this->collectSpecEnumNames( $value, $enums, - $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName) + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName), + $key === 'items' && $itemsEnumHandled ); } } From e0fec8f550b098d77192758419bef031cf62ceb1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 15:42:17 +0530 Subject: [PATCH 105/123] updated --- app/realtime.php | 68 +++++++++++++++++++++++ src/Appwrite/SDK/Specification/Format.php | 7 +-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 3461ca83e5..13ebb609a8 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -38,6 +38,7 @@ use Utopia\DSN\DSN; use Utopia\Logger\Log; use Utopia\Pools\Group; use Utopia\Registry\Registry; +use Utopia\Span\Span; use Utopia\System\System; use Utopia\Telemetry\Adapter\None as NoTelemetry; use Utopia\WebSocket\Adapter; @@ -326,11 +327,32 @@ if (!function_exists('logError')) { } } +if (!function_exists('traceOperationalEvent')) { + function traceOperationalEvent(string $action, string $message, array $context = []): void + { + Span::init($action); + Span::add('realtime.action', $action); + Span::add('realtime.message', $message); + Span::add('realtime.timestamp', DateTime::formatTz(DateTime::now())); + + foreach ($context as $key => $value) { + if (\is_scalar($value) || $value === null) { + Span::add('realtime.' . $key, ($value === null || $value === '') ? 'n/a' : $value); + } + } + + Span::current()?->finish(); + } +} + $server->error(logError(...)); $server->onStart(function () use ($stats, $containerId, &$statsDocument) { sleep(5); // wait for the initial database schema to be ready Console::success('Server started successfully'); + traceOperationalEvent('realtime.server.started', 'Realtime server started', [ + 'container' => $containerId, + ]); /** * Create document for this worker to share stats across Containers. @@ -394,6 +416,9 @@ $server->onStart(function () use ($stats, $containerId, &$statsDocument) { $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) { Console::success('Worker ' . $workerId . ' started successfully'); + traceOperationalEvent('realtime.worker.started', 'Realtime worker started', [ + 'workerId' => $workerId, + ]); $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; @@ -526,6 +551,9 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($pubsub->ping(true)) { $attempts = 0; Console::success('Pub/sub connection established (worker: ' . $workerId . ')'); + traceOperationalEvent('realtime.pubsub.connected', 'Realtime pubsub connected', [ + 'workerId' => $workerId, + ]); } else { Console::error('Pub/sub failed (worker: ' . $workerId . ')'); } @@ -858,6 +886,14 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + traceOperationalEvent('realtime.connection.opened', 'Realtime connection established', [ + 'connectionId' => $connection, + 'projectId' => $project->getId(), + 'teamId' => $project->getAttribute('teamId'), + 'userId' => $logUser?->getId() ?: null, + 'channelCount' => \count($names), + 'subscriptionCount' => \count($mapping), + ]); } catch (Throwable $th) { @@ -1053,6 +1089,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $authResponsePayloadJson); + traceOperationalEvent('realtime.authentication.succeeded', 'Realtime authentication succeeded', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'userId' => $user['$id'] ?? null, + 'subscriptionDelta' => $subscriptionDelta, + ]); if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1149,6 +1191,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $responsePayload); + traceOperationalEvent('realtime.subscribe.updated', 'Realtime subscriptions updated', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'subscriptionCount' => \count($parsedPayloads), + 'subscriptionDelta' => $subscriptionDelta, + ]); if ($project !== null && !$project->isEmpty()) { $subscribeOutboundBytes = \strlen($responsePayload); @@ -1208,6 +1256,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $unsubscribeResponsePayload); + traceOperationalEvent('realtime.unsubscribe.updated', 'Realtime subscriptions removed', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'requestedCount' => \count($validatedIds), + 'removedCount' => \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)), + 'subscriptionDelta' => $subscriptionDelta, + ]); if ($project !== null && !$project->isEmpty()) { $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); @@ -1255,6 +1310,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re }); $server->onClose(function (int $connection) use ($realtime, $stats, $register) { + $projectId = null; + $userId = null; + + if (array_key_exists($connection, $realtime->connections)) { + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $userId = $realtime->connections[$connection]['userId'] ?? null; + } + try { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); @@ -1278,6 +1341,11 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { } $realtime->unsubscribe($connection); + traceOperationalEvent('realtime.connection.closed', 'Realtime connection closed', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'userId' => $userId, + ]); Console::info('Connection close: ' . $connection); }); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 08f960b2a7..d48a4b8f3f 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -742,6 +742,7 @@ abstract class Format } break; case 'project': + case 'projects': switch ($method) { case 'getUsage': switch ($param) { @@ -749,10 +750,6 @@ abstract class Format return 'ProjectUsageRange'; } break; - } - break; - case 'projects': - switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': case 'deleteEmailTemplate': @@ -770,7 +767,9 @@ abstract class Format } break; case 'createSmtpTest': + case 'createSMTPTest': case 'updateSmtp': + case 'updateSMTP': switch ($param) { case 'secure': return 'SMTPSecure'; From 00512df4caebe7d735f718cb72023c8752bc301a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:45:16 +0530 Subject: [PATCH 106/123] Clean up spec enum validation reporting --- src/Appwrite/Platform/Tasks/Specs.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index ce1d78799d..82020b05b1 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -331,7 +331,7 @@ class Specs extends Action } foreach ($enums[$normalized] as $enum) { - $overlaps[] = "{$enum} (service '{$service}', enum '{$enum}')"; + $overlaps[] = "service '{$service}' with enum '{$enum}'"; } } @@ -373,7 +373,7 @@ class Specs extends Action } $explicitEnumName = $this->getExplicitSpecEnumName($node); - if (!\is_null($explicitEnumName) && !isset($node['enum'])) { + if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) { $this->addSpecEnumName($enums, $explicitEnumName); } From d106e1d5bb06c027b435cde4909b546323dd6f5c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:57:32 +0530 Subject: [PATCH 107/123] Fix session alert test payload --- tests/e2e/Services/Project/TemplatesBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 72a14210a5..d415c3267d 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -685,7 +685,7 @@ trait TemplatesBase 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ], - ['alerts' => true], + ['enabled' => true], ); $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); From e7d9ef74c4aa8c650cb9d5a290f44f6af47cd680 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:05:12 +0530 Subject: [PATCH 108/123] Fix deployment single chunk content range Fixes CLOUD-3JN6 --- .../Functions/Http/Deployments/Create.php | 6 +++ .../Modules/Sites/Http/Deployments/Create.php | 6 +++ .../Functions/FunctionsCustomServerTest.php | 38 ++++++++++++++++++ .../Services/Sites/SitesCustomServerTest.php | 40 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 65b6ffd5bb..11736c8ca5 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -206,6 +206,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 8a6964209f..0b8ca24aaa 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -208,6 +208,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index ba518ee0b6..4255774f18 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -567,6 +567,44 @@ class FunctionsCustomServerTest extends Scope }, 120000, 500); } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Single Chunk Range', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $code = $this->packageFunction('basic'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->getDeployment($functionId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupFunction($functionId); + } + public function testCreateFunctionAndDeploymentFromTemplate() { diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 69dbd7fdf0..645ad031f5 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -868,6 +868,46 @@ class SitesCustomServerTest extends Scope // // TODO: Implement testCreateDeploymentFromCLI() later // } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site Single Chunk Range', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + $code = $this->packageSite('static-single-file'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupSite($siteId); + } + public function testCreateDeployment() { $siteId = $this->setupSite([ From f934259c31d31c016e7aff14b5127c95fff55f21 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:22:23 +0530 Subject: [PATCH 109/123] Skip preview rule when no deployment exists on function create --- .../Platform/Modules/Functions/Http/Functions/Create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 8d4ad5d403..cd2ba6e451 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -375,7 +375,7 @@ class Create extends Base } $functionsDomain = $platform['functionsDomain']; - if (!empty($functionsDomain)) { + if (!empty($functionsDomain) && isset($deployment) && !$deployment->isEmpty()) { $routeSubdomain = ID::unique(); $domain = "{$routeSubdomain}.{$functionsDomain}"; // TODO: (@Meldiron) Remove after 1.7.x migration From f50ca0281b2ba768d6941c9135b6a4a6cffa4b9d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:43:28 +0530 Subject: [PATCH 110/123] Drop dead ternary guards now that outer check ensures deployment --- .../Platform/Modules/Functions/Http/Functions/Create.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index cd2ba6e451..7b294f3f90 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -391,8 +391,8 @@ class Create extends Base 'status' => 'verified', 'type' => 'deployment', 'trigger' => 'manual', - 'deploymentId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getId(), - 'deploymentInternalId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getSequence(), + 'deploymentId' => $deployment->getId(), + 'deploymentInternalId' => $deployment->getSequence(), 'deploymentResourceType' => 'function', 'deploymentResourceId' => $function->getId(), 'deploymentResourceInternalId' => $function->getSequence(), From ca1cf1982f5a8664a39b8a9752b046c8578aee94 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:44:20 +0530 Subject: [PATCH 111/123] added wide events inside the structured one logging per message instead of a discrete logs --- app/realtime.php | 105 +++++++++++++++++++---------------------------- 1 file changed, 43 insertions(+), 62 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 13ebb609a8..835ddf932f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -327,32 +327,11 @@ if (!function_exists('logError')) { } } -if (!function_exists('traceOperationalEvent')) { - function traceOperationalEvent(string $action, string $message, array $context = []): void - { - Span::init($action); - Span::add('realtime.action', $action); - Span::add('realtime.message', $message); - Span::add('realtime.timestamp', DateTime::formatTz(DateTime::now())); - - foreach ($context as $key => $value) { - if (\is_scalar($value) || $value === null) { - Span::add('realtime.' . $key, ($value === null || $value === '') ? 'n/a' : $value); - } - } - - Span::current()?->finish(); - } -} - $server->error(logError(...)); $server->onStart(function () use ($stats, $containerId, &$statsDocument) { sleep(5); // wait for the initial database schema to be ready Console::success('Server started successfully'); - traceOperationalEvent('realtime.server.started', 'Realtime server started', [ - 'container' => $containerId, - ]); /** * Create document for this worker to share stats across Containers. @@ -416,9 +395,6 @@ $server->onStart(function () use ($stats, $containerId, &$statsDocument) { $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) { Console::success('Worker ' . $workerId . ' started successfully'); - traceOperationalEvent('realtime.worker.started', 'Realtime worker started', [ - 'workerId' => $workerId, - ]); $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; @@ -551,9 +527,6 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($pubsub->ping(true)) { $attempts = 0; Console::success('Pub/sub connection established (worker: ' . $workerId . ')'); - traceOperationalEvent('realtime.pubsub.connected', 'Realtime pubsub connected', [ - 'workerId' => $workerId, - ]); } else { Console::error('Pub/sub failed (worker: ' . $workerId . ')'); } @@ -886,14 +859,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); - traceOperationalEvent('realtime.connection.opened', 'Realtime connection established', [ - 'connectionId' => $connection, - 'projectId' => $project->getId(), - 'teamId' => $project->getAttribute('teamId'), - 'userId' => $logUser?->getId() ?: null, - 'channelCount' => \count($names), - 'subscriptionCount' => \count($mapping), - ]); } catch (Throwable $th) { @@ -935,10 +900,24 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) { $project = null; $authorization = null; + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $rawSize = \strlen($message); + $messageType = 'invalid'; + $subscriptionDelta = 0; + $subscriptionsRequested = 0; + $subscriptionsRemoved = 0; + $outboundBytes = 0; + $responseCode = 200; + $success = false; + + Span::init('realtime.message'); + Span::add('realtime.connection_id', $connection); + Span::add('realtime.project_id', $projectId ?: 'n/a'); + Span::add('realtime.inbound_bytes', $rawSize); + Span::add('realtime.container_id', $containerId); + try { - $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); - $projectId = $realtime->connections[$connection]['projectId'] ?? null; // Get authorization from connection (stored during onOpen) $authorization = $realtime->connections[$connection]['authorization'] ?? null; @@ -983,6 +962,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $message = json_decode($message, true); + $messageType = $message['type'] ?? 'invalid'; + Span::add('realtime.message_type', $messageType); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); @@ -1000,6 +981,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $pongPayloadJson); + $outboundBytes += \strlen($pongPayloadJson); if ($project !== null && !$project->isEmpty()) { $pongOutboundBytes = \strlen($pongPayloadJson); @@ -1089,12 +1071,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $authResponsePayloadJson); - traceOperationalEvent('realtime.authentication.succeeded', 'Realtime authentication succeeded', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'userId' => $user['$id'] ?? null, - 'subscriptionDelta' => $subscriptionDelta, - ]); + $outboundBytes += \strlen($authResponsePayloadJson); + Span::add('realtime.user_id', $user['$id'] ?? 'n/a'); if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1171,6 +1149,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($parsedPayloads); if ($subscriptionDelta !== 0) { $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); } @@ -1191,12 +1170,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $responsePayload); - traceOperationalEvent('realtime.subscribe.updated', 'Realtime subscriptions updated', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'subscriptionCount' => \count($parsedPayloads), - 'subscriptionDelta' => $subscriptionDelta, - ]); + $outboundBytes += \strlen($responsePayload); if ($project !== null && !$project->isEmpty()) { $subscribeOutboundBytes = \strlen($responsePayload); @@ -1242,6 +1216,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($validatedIds); + $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)); if ($subscriptionDelta !== 0) { $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); } @@ -1256,13 +1232,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $unsubscribeResponsePayload); - traceOperationalEvent('realtime.unsubscribe.updated', 'Realtime subscriptions removed', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'requestedCount' => \count($validatedIds), - 'removedCount' => \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)), - 'subscriptionDelta' => $subscriptionDelta, - ]); + $outboundBytes += \strlen($unsubscribeResponsePayload); if ($project !== null && !$project->isEmpty()) { $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); @@ -1279,12 +1249,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } + $success = true; } catch (Throwable $th) { logError($th, 'realtimeMessage', project: $project, authorization: $authorization); $code = $th->getCode(); if (!is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -1301,11 +1273,25 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); if ($th->getCode() === 1008) { $server->close($connection, $th->getCode()); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.response_code', $responseCode); + Span::add('realtime.subscription_delta', $subscriptionDelta); + Span::add('realtime.subscriptions_requested', $subscriptionsRequested); + Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); + Span::add('realtime.outbound_bytes', $outboundBytes); + Span::add('realtime.project_id', $project?->getId() ?: $projectId ?: 'n/a'); + Span::add('realtime.user_id', $realtime->connections[$connection]['userId'] ?? 'n/a'); + Span::add('realtime.message_type', $messageType); + Span::current()?->finish(); } }); @@ -1341,11 +1327,6 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { } $realtime->unsubscribe($connection); - traceOperationalEvent('realtime.connection.closed', 'Realtime connection closed', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'userId' => $userId, - ]); Console::info('Connection close: ' . $connection); }); From 57d777f80a21bdd22ae5da1e5fca39eef1ff2113 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:46:18 +0530 Subject: [PATCH 112/123] revert format --- src/Appwrite/SDK/Specification/Format.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 243517445e..30df5acf52 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -742,7 +742,6 @@ abstract class Format } break; case 'project': - case 'projects': switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': @@ -759,6 +758,10 @@ abstract class Format return 'ProjectUsageRange'; } break; + } + break; + case 'projects': + switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': switch ($param) { @@ -775,9 +778,7 @@ abstract class Format } break; case 'createSmtpTest': - case 'createSMTPTest': case 'updateSmtp': - case 'updateSMTP': switch ($param) { case 'secure': return 'SMTPSecure'; From b2ad7237abf61152b4c5b84c5b826d599b90823b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:52:54 +0530 Subject: [PATCH 113/123] Add detailed telemetry logging for realtime connection events - Introduced span logging for connection open and close events, capturing metrics such as inbound and outbound bytes, subscription counts, and response codes. - Enhanced error handling with logging of exceptions during connection lifecycle. - Updated the structure of the telemetry data to include project and user IDs for better traceability. --- app/realtime.php | 54 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 835ddf932f..8402dda1f0 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -702,11 +702,24 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $project = null; $logUser = null; $authorization = null; + $rawSize = $request->getSize(); + $channelCount = 0; + $subscriptionCount = 0; + $outboundBytes = 0; + $responseCode = 200; + $subscriptionMode = 'message'; + $success = false; + + Span::init('realtime.open'); + Span::add('realtime.connection_id', $connection); + Span::add('realtime.inbound_bytes', $rawSize); + Span::add('realtime.origin', $request->getOrigin() ?: 'n/a'); try { /** @var Document $project */ $project = $connectionContainer->get('project'); $authorization = $connectionContainer->get('authorization'); + Span::add('realtime.project_id', $project->getId() ?: 'n/a'); /* * Project Check @@ -751,8 +764,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests'); } - $rawSize = $request->getSize(); - triggerStats([ METRIC_REALTIME_INBOUND => $rawSize, ], $project->getId()); @@ -770,8 +781,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $roles = $user->getRoles($authorization); + Span::add('realtime.user_id', $user->getId() ?: 'n/a'); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $channelCount = \count($channels); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); @@ -809,11 +822,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + $subscriptionMode = 'message'; + $success = true; return; } $names = array_keys($channels); + $subscriptionMode = 'url'; try { $subscriptions = Realtime::constructSubscriptions( @@ -840,6 +857,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping[$index] = $subscriptionId; } + $subscriptionCount = \count($subscriptions); if (!empty($subscriptions)) { $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes')); } @@ -858,8 +876,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); - + $success = true; } catch (Throwable $th) { logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization); @@ -869,6 +888,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, if (!\is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -886,7 +906,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); $server->close($connection, $code); if (System::getEnv('_APP_ENV', 'production') === 'development') { @@ -894,6 +916,17 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Console::error('[Error] Code: ' . $response['data']['code']); Console::error('[Error] Message: ' . $response['data']['message']); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.response_code', $responseCode); + Span::add('realtime.subscription_mode', $subscriptionMode); + Span::add('realtime.channel_count', $channelCount); + Span::add('realtime.subscription_count', $subscriptionCount); + Span::add('realtime.outbound_bytes', $outboundBytes); + Span::add('realtime.project_id', $project?->getId() ?: 'n/a'); + Span::add('realtime.user_id', $logUser?->getId() ?: 'n/a'); + Span::current()?->finish(); } }); @@ -1298,6 +1331,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $server->onClose(function (int $connection) use ($realtime, $stats, $register) { $projectId = null; $userId = null; + $subscriptionsBeforeClose = 0; + $success = false; + + Span::init('realtime.close'); + Span::add('realtime.connection_id', $connection); if (array_key_exists($connection, $realtime->connections)) { $projectId = $realtime->connections[$connection]['projectId'] ?? null; @@ -1320,12 +1358,20 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { METRIC_REALTIME_CONNECTIONS => -1, ], $projectId); } + $success = true; } catch (\Throwable $th) { // Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine // backtraces and unsubscribe() below would never run (connection cleanup would fail). Console::error('Realtime onClose error: ' . $th->getMessage()); + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.project_id', $projectId ?: 'n/a'); + Span::add('realtime.user_id', $userId ?: 'n/a'); + Span::add('realtime.subscriptions_before_close', $subscriptionsBeforeClose); } $realtime->unsubscribe($connection); + Span::current()?->finish(); Console::info('Connection close: ' . $connection); }); From 17e3d03b40e1a5792b80104b998c3f7275022e4e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:55:52 +0530 Subject: [PATCH 114/123] Add telemetry logging for subscribed channels and queries in realtime events - Introduced new arrays to capture subscribed channels and passed queries during connection and message events. - Enhanced span logging to include details about channels and queries for better monitoring and analysis. - Updated telemetry data structure to reflect the new metrics, improving traceability of realtime interactions. --- app/realtime.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 8402dda1f0..16aba56250 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -705,6 +705,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $rawSize = $request->getSize(); $channelCount = 0; $subscriptionCount = 0; + $urlSubscribedChannels = []; + $urlPassedQueries = []; $outboundBytes = 0; $responseCode = 200; $subscriptionMode = 'message'; @@ -785,6 +787,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); + $urlSubscribedChannels = \array_values(\array_keys($channels)); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); @@ -844,6 +847,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping = []; foreach ($subscriptions as $index => $subscription) { $subscriptionId = ID::unique(); + $urlPassedQueries[$index] = \array_map( + fn ($query) => $query instanceof Query ? $query->toString() : (string) $query, + $subscription['queries'] ?? [] + ); $realtime->subscribe( $project->getId(), @@ -923,6 +930,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Span::add('realtime.subscription_mode', $subscriptionMode); Span::add('realtime.channel_count', $channelCount); Span::add('realtime.subscription_count', $subscriptionCount); + Span::add('realtime.channels_subscribed', json_encode($urlSubscribedChannels)); + Span::add('realtime.queries_passed', json_encode($urlPassedQueries)); Span::add('realtime.outbound_bytes', $outboundBytes); Span::add('realtime.project_id', $project?->getId() ?: 'n/a'); Span::add('realtime.user_id', $logUser?->getId() ?: 'n/a'); @@ -939,6 +948,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionDelta = 0; $subscriptionsRequested = 0; $subscriptionsRemoved = 0; + $subscribeChannelsPassed = []; + $subscribeQueriesPassed = []; $outboundBytes = 0; $responseCode = 200; $success = false; @@ -1172,6 +1183,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'channels' => $payload['channels'], 'queries' => $convertedQueries, ]; + + $subscribeChannelsPassed[] = $payload['channels']; + $subscribeQueriesPassed[] = $payload['queries']; } foreach ($parsedPayloads as $parsedPayload) { @@ -1320,6 +1334,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::add('realtime.subscription_delta', $subscriptionDelta); Span::add('realtime.subscriptions_requested', $subscriptionsRequested); Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); + Span::add('realtime.subscribe.channels_passed', json_encode($subscribeChannelsPassed)); + Span::add('realtime.subscribe.queries_passed', json_encode($subscribeQueriesPassed)); + Span::add('realtime.subscribe.subscriptions_count', \count($subscribeChannelsPassed)); Span::add('realtime.outbound_bytes', $outboundBytes); Span::add('realtime.project_id', $project?->getId() ?: $projectId ?: 'n/a'); Span::add('realtime.user_id', $realtime->connections[$connection]['userId'] ?? 'n/a'); From 0f81bc2da9fd312e6a9060fb85cdda5d586376f3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:57:51 +0530 Subject: [PATCH 115/123] Refactor telemetry logging in realtime events for consistency and clarity - Updated span logging keys to use camelCase for uniformity across connection and message events. - Added checks to ensure project and user IDs are only logged if they are not empty, enhancing data integrity. - Improved error handling and logging structure to maintain consistency in telemetry data. --- app/realtime.php | 92 ++++++++++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 16aba56250..91d392b77c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -713,15 +713,19 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $success = false; Span::init('realtime.open'); - Span::add('realtime.connection_id', $connection); - Span::add('realtime.inbound_bytes', $rawSize); - Span::add('realtime.origin', $request->getOrigin() ?: 'n/a'); + Span::add('realtime.connectionId', $connection); + Span::add('realtime.inboundBytes', $rawSize); + if (!empty($request->getOrigin())) { + Span::add('realtime.origin', $request->getOrigin()); + } try { /** @var Document $project */ $project = $connectionContainer->get('project'); $authorization = $connectionContainer->get('authorization'); - Span::add('realtime.project_id', $project->getId() ?: 'n/a'); + if (!empty($project->getId())) { + Span::add('realtime.projectId', $project->getId()); + } /* * Project Check @@ -783,7 +787,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $roles = $user->getRoles($authorization); - Span::add('realtime.user_id', $user->getId() ?: 'n/a'); + Span::add('realtime.userId', $user->getId()); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); @@ -926,15 +930,19 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Span::error($th); } finally { Span::add('realtime.success', $success); - Span::add('realtime.response_code', $responseCode); - Span::add('realtime.subscription_mode', $subscriptionMode); - Span::add('realtime.channel_count', $channelCount); - Span::add('realtime.subscription_count', $subscriptionCount); - Span::add('realtime.channels_subscribed', json_encode($urlSubscribedChannels)); - Span::add('realtime.queries_passed', json_encode($urlPassedQueries)); - Span::add('realtime.outbound_bytes', $outboundBytes); - Span::add('realtime.project_id', $project?->getId() ?: 'n/a'); - Span::add('realtime.user_id', $logUser?->getId() ?: 'n/a'); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionMode', $subscriptionMode); + Span::add('realtime.channelCount', $channelCount); + Span::add('realtime.subscriptionCount', $subscriptionCount); + Span::add('realtime.channelsSubscribed', json_encode($urlSubscribedChannels)); + Span::add('realtime.queriesPassed', json_encode($urlPassedQueries)); + Span::add('realtime.outboundBytes', $outboundBytes); + if (!empty($project?->getId())) { + Span::add('realtime.projectId', $project->getId()); + } + if (!empty($logUser?->getId())) { + Span::add('realtime.userId', $logUser->getId()); + } Span::current()?->finish(); } }); @@ -955,10 +963,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $success = false; Span::init('realtime.message'); - Span::add('realtime.connection_id', $connection); - Span::add('realtime.project_id', $projectId ?: 'n/a'); - Span::add('realtime.inbound_bytes', $rawSize); - Span::add('realtime.container_id', $containerId); + Span::add('realtime.connectionId', $connection); + if (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + Span::add('realtime.inboundBytes', $rawSize); + Span::add('realtime.containerId', $containerId); try { $response = new Response(new SwooleResponse()); @@ -1007,7 +1017,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $message = json_decode($message, true); $messageType = $message['type'] ?? 'invalid'; - Span::add('realtime.message_type', $messageType); + Span::add('realtime.messageType', $messageType); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); @@ -1116,7 +1126,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $server->send([$connection], $authResponsePayloadJson); $outboundBytes += \strlen($authResponsePayloadJson); - Span::add('realtime.user_id', $user['$id'] ?? 'n/a'); + if (!empty($user['$id'] ?? null)) { + Span::add('realtime.userId', $user['$id']); + } if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1330,17 +1342,23 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::error($th); } finally { Span::add('realtime.success', $success); - Span::add('realtime.response_code', $responseCode); - Span::add('realtime.subscription_delta', $subscriptionDelta); - Span::add('realtime.subscriptions_requested', $subscriptionsRequested); - Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); - Span::add('realtime.subscribe.channels_passed', json_encode($subscribeChannelsPassed)); - Span::add('realtime.subscribe.queries_passed', json_encode($subscribeQueriesPassed)); - Span::add('realtime.subscribe.subscriptions_count', \count($subscribeChannelsPassed)); - Span::add('realtime.outbound_bytes', $outboundBytes); - Span::add('realtime.project_id', $project?->getId() ?: $projectId ?: 'n/a'); - Span::add('realtime.user_id', $realtime->connections[$connection]['userId'] ?? 'n/a'); - Span::add('realtime.message_type', $messageType); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionDelta', $subscriptionDelta); + Span::add('realtime.subscriptionsRequested', $subscriptionsRequested); + Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved); + Span::add('realtime.subscribe.channelsPassed', json_encode($subscribeChannelsPassed)); + Span::add('realtime.subscribe.queriesPassed', json_encode($subscribeQueriesPassed)); + Span::add('realtime.subscribe.subscriptionsCount', \count($subscribeChannelsPassed)); + Span::add('realtime.outboundBytes', $outboundBytes); + if (!empty($project?->getId())) { + Span::add('realtime.projectId', $project->getId()); + } elseif (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + if (!empty($realtime->connections[$connection]['userId'] ?? null)) { + Span::add('realtime.userId', $realtime->connections[$connection]['userId']); + } + Span::add('realtime.messageType', $messageType); Span::current()?->finish(); } }); @@ -1352,7 +1370,7 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { $success = false; Span::init('realtime.close'); - Span::add('realtime.connection_id', $connection); + Span::add('realtime.connectionId', $connection); if (array_key_exists($connection, $realtime->connections)) { $projectId = $realtime->connections[$connection]['projectId'] ?? null; @@ -1383,9 +1401,13 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Span::error($th); } finally { Span::add('realtime.success', $success); - Span::add('realtime.project_id', $projectId ?: 'n/a'); - Span::add('realtime.user_id', $userId ?: 'n/a'); - Span::add('realtime.subscriptions_before_close', $subscriptionsBeforeClose); + if (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + if (!empty($userId)) { + Span::add('realtime.userId', $userId); + } + Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); } $realtime->unsubscribe($connection); Span::current()?->finish(); From 59e0383264950063a4d65fd085a29b0505f144f8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:01:08 +0530 Subject: [PATCH 116/123] updated --- app/realtime.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 91d392b77c..e161f08545 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1017,12 +1017,17 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $message = json_decode($message, true); $messageType = $message['type'] ?? 'invalid'; - Span::add('realtime.messageType', $messageType); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + if (!\is_scalar($messageType) && $messageType !== null) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); + } + + Span::add('realtime.messageType', $messageType); + // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); From fd9fe5d9ce79c4bc04428409aef5f37d14cf25f8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:07:53 +0530 Subject: [PATCH 117/123] corrected the position --- app/realtime.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index e161f08545..fcca7c9c42 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1195,9 +1195,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); } + $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); + $parsedPayloads[] = [ 'subscriptionId' => $subscriptionId, 'channels' => $payload['channels'], + 'convertedChannels' => $convertedChannels, 'queries' => $convertedQueries, ]; @@ -1207,7 +1210,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re foreach ($parsedPayloads as $parsedPayload) { $subscriptionId = $parsedPayload['subscriptionId']; - $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId)); + $channels = $parsedPayload['convertedChannels']; $queries = $parsedPayload['queries']; $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } @@ -1226,7 +1229,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'subscriptions' => \array_map(function (array $parsedPayload) { return [ 'subscriptionId' => $parsedPayload['subscriptionId'], - 'channels' => $parsedPayload['channels'], + 'channels' => $parsedPayload['convertedChannels'], 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']), ]; }, $parsedPayloads), @@ -1413,9 +1416,9 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Span::add('realtime.userId', $userId); } Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); + Span::current()?->finish(); } $realtime->unsubscribe($connection); - Span::current()?->finish(); Console::info('Connection close: ' . $connection); }); From 46e778ea90269414eed652f6d1b7a98f25b13a31 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:13:35 +0530 Subject: [PATCH 118/123] updated --- app/realtime.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index fcca7c9c42..521a80a82f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1016,12 +1016,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $message = json_decode($message, true); - $messageType = $message['type'] ?? 'invalid'; if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + $messageType = $message['type'] ?? 'invalid'; + if (!\is_scalar($messageType) && $messageType !== null) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } @@ -1408,6 +1409,13 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Console::error('Realtime onClose error: ' . $th->getMessage()); Span::error($th); } finally { + try { + $realtime->unsubscribe($connection); + } catch (\Throwable $th) { + Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage()); + Span::error($th); + } + Span::add('realtime.success', $success); if (!empty($projectId)) { Span::add('realtime.projectId', $projectId); @@ -1418,7 +1426,6 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); Span::current()?->finish(); } - $realtime->unsubscribe($connection); Console::info('Connection close: ' . $connection); }); From f0f1e1c412bad80f33dbe3591370a400feb5fa7b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:44:18 +0530 Subject: [PATCH 119/123] updated --- app/realtime.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 521a80a82f..eeaa56d30a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1023,7 +1023,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $messageType = $message['type'] ?? 'invalid'; - if (!\is_scalar($messageType) && $messageType !== null) { + if (!\is_scalar($messageType)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } @@ -1285,7 +1285,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; $subscriptionsRequested = \count($validatedIds); - $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)); + $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'])); if ($subscriptionDelta !== 0) { $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); } From 6d1def7716c1265c3df3fa3630a8a86fa1a1cac9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:45:58 +0530 Subject: [PATCH 120/123] removed redundant span attributes --- app/realtime.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index eeaa56d30a..572256eec6 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -723,9 +723,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, /** @var Document $project */ $project = $connectionContainer->get('project'); $authorization = $connectionContainer->get('authorization'); - if (!empty($project->getId())) { - Span::add('realtime.projectId', $project->getId()); - } /* * Project Check @@ -787,7 +784,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $roles = $user->getRoles($authorization); - Span::add('realtime.userId', $user->getId()); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); @@ -1027,8 +1023,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } - Span::add('realtime.messageType', $messageType); - // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); From b006858d0c34e3df26623324723a9c113e5436bd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:52:45 +0530 Subject: [PATCH 121/123] dedupe --- app/realtime.php | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 572256eec6..d1177ac07b 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -960,9 +960,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::init('realtime.message'); Span::add('realtime.connectionId', $connection); - if (!empty($projectId)) { - Span::add('realtime.projectId', $projectId); - } Span::add('realtime.inboundBytes', $rawSize); Span::add('realtime.containerId', $containerId); @@ -1126,9 +1123,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $server->send([$connection], $authResponsePayloadJson); $outboundBytes += \strlen($authResponsePayloadJson); - if (!empty($user['$id'] ?? null)) { - Span::add('realtime.userId', $user['$id']); - } if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1353,14 +1347,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::add('realtime.subscribe.queriesPassed', json_encode($subscribeQueriesPassed)); Span::add('realtime.subscribe.subscriptionsCount', \count($subscribeChannelsPassed)); Span::add('realtime.outboundBytes', $outboundBytes); - if (!empty($project?->getId())) { - Span::add('realtime.projectId', $project->getId()); - } elseif (!empty($projectId)) { - Span::add('realtime.projectId', $projectId); - } - if (!empty($realtime->connections[$connection]['userId'] ?? null)) { - Span::add('realtime.userId', $realtime->connections[$connection]['userId']); - } + Span::add('realtime.projectId', $project?->getId() ?? $projectId); + Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null); Span::add('realtime.messageType', $messageType); Span::current()?->finish(); } From b2d24080b9beda3ee7ae4e410444c90b5cb96943 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 17:24:49 +0530 Subject: [PATCH 122/123] Stabilize database e2e CI retries --- .github/workflows/ci.yml | 15 +++++++++++++-- tests/e2e/Services/Migrations/MigrationsBase.php | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e01839ac6..e7307d3c3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -429,6 +429,8 @@ jobs: include: - service: Databases runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 - service: Sites runner: blacksmith-4vcpu-ubuntu-2404 - service: Functions @@ -439,6 +441,10 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - service: TablesDB runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 + - service: Migrations + paratest_processes: 1 steps: - name: Checkout repository uses: actions/checkout@v6 @@ -499,7 +505,7 @@ jobs: with: max_attempts: 2 retry_wait_seconds: 60 - timeout_minutes: 20 + timeout_minutes: ${{ matrix.timeout_minutes || 20 }} job_id: ${{ job.check_run_id }} github_token: ${{ secrets.GITHUB_TOKEN }} test_dir: tests/e2e/Services/${{ matrix.service }} @@ -512,9 +518,14 @@ jobs: Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;; esac + PARATEST_PROCESSES="${{ matrix.paratest_processes }}" + if [ -z "$PARATEST_PROCESSES" ]; then + PARATEST_PROCESSES="$(nproc)" + fi + docker compose exec -T \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml + appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - name: Failure Logs if: failure() diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 9e9ce2fbcd..069dc9cfbb 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -4207,7 +4207,9 @@ trait MigrationsBase }, 30_000, 500); // Check that email was sent with download link - $lastEmail = $this->getLastEmail(); + $lastEmail = $this->getLastEmail(probe: function ($email) { + $this->assertEquals('Your JSON export is ready', $email['subject']); + }); $this->assertNotEmpty($lastEmail); $this->assertEquals('Your JSON export is ready', $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); From c2e5bbe0f738ac4a16a1a9924656f29afd1fa19f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 18:11:32 +0530 Subject: [PATCH 123/123] updated --- app/realtime.php | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index d1177ac07b..71aa251069 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -705,8 +705,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $rawSize = $request->getSize(); $channelCount = 0; $subscriptionCount = 0; - $urlSubscribedChannels = []; - $urlPassedQueries = []; $outboundBytes = 0; $responseCode = 200; $subscriptionMode = 'message'; @@ -787,7 +785,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); - $urlSubscribedChannels = \array_values(\array_keys($channels)); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); @@ -847,10 +844,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping = []; foreach ($subscriptions as $index => $subscription) { $subscriptionId = ID::unique(); - $urlPassedQueries[$index] = \array_map( - fn ($query) => $query instanceof Query ? $query->toString() : (string) $query, - $subscription['queries'] ?? [] - ); $realtime->subscribe( $project->getId(), @@ -930,8 +923,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Span::add('realtime.subscriptionMode', $subscriptionMode); Span::add('realtime.channelCount', $channelCount); Span::add('realtime.subscriptionCount', $subscriptionCount); - Span::add('realtime.channelsSubscribed', json_encode($urlSubscribedChannels)); - Span::add('realtime.queriesPassed', json_encode($urlPassedQueries)); Span::add('realtime.outboundBytes', $outboundBytes); if (!empty($project?->getId())) { Span::add('realtime.projectId', $project->getId()); @@ -952,8 +943,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionDelta = 0; $subscriptionsRequested = 0; $subscriptionsRemoved = 0; - $subscribeChannelsPassed = []; - $subscribeQueriesPassed = []; $outboundBytes = 0; $responseCode = 200; $success = false; @@ -1192,9 +1181,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'convertedChannels' => $convertedChannels, 'queries' => $convertedQueries, ]; - - $subscribeChannelsPassed[] = $payload['channels']; - $subscribeQueriesPassed[] = $payload['queries']; } foreach ($parsedPayloads as $parsedPayload) { @@ -1343,9 +1329,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::add('realtime.subscriptionDelta', $subscriptionDelta); Span::add('realtime.subscriptionsRequested', $subscriptionsRequested); Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved); - Span::add('realtime.subscribe.channelsPassed', json_encode($subscribeChannelsPassed)); - Span::add('realtime.subscribe.queriesPassed', json_encode($subscribeQueriesPassed)); - Span::add('realtime.subscribe.subscriptionsCount', \count($subscribeChannelsPassed)); + Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested); Span::add('realtime.outboundBytes', $outboundBytes); Span::add('realtime.projectId', $project?->getId() ?? $projectId); Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null);