From bed46a6bef1709002eb25c08e8673de31a21b7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:12:23 +0100 Subject: [PATCH 01/23] Fix redirect url approval for oauth flow --- app/init/resources.php | 77 +++++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index ccbb703f50..9227b0fe99 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -236,45 +236,68 @@ Http::setResource('allowedSchemes', function (Document $project) { * Rule associated with a request origin. */ Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) { - $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); - if (empty($domain)) { + $domains = []; + + $originDomain = \parse_url($request->getOrigin(), PHP_URL_HOST); + if (!empty($originDomain)) { + $domains[] = $originDomain; + } + + $refererDomain = \parse_url($request->getReferer(), PHP_URL_HOST); + if (!empty($refererDomain)) { + $domains[] = $refererDomain; + } + + if (\count($domains) === 0) { return new Document(); } - // TODO: (@Meldiron) Remove after 1.7.x migration - $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; - $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { - if ($isMd5) { - return $dbForPlatform->getDocument('rules', md5($domain)); - } + $permittedRule = null; - return $dbForPlatform->findOne('rules', [ - Query::equal('domain', [$domain]), - ]) ?? new Document(); - }); - - $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); - - // Temporary implementation until custom wildcard domains are an official feature - // Allow trusted projects; Used for Console (website) previews - if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { - $trustedProjects = []; - foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { - if (empty($trustedProject)) { - continue; + foreach ($domains as $domain) { + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); } - $trustedProjects[] = $trustedProject; + + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); + + if ($rule->isEmpty()) { + continue; } - if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { - $permitsCurrentProject = true; + + $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + + // Temporary implementation until custom wildcard domains are an official feature + // Allow trusted projects; Used for Console (website) previews + if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { + $trustedProjects = []; + foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { + if (empty($trustedProject)) { + continue; + } + $trustedProjects[] = $trustedProject; + } + if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { + $permitsCurrentProject = true; + } + } + + if ($permitsCurrentProject) { + $permittedRule = $rule; } } - if (!$permitsCurrentProject) { + if (\is_null($permittedRule)) { return new Document(); } - return $rule; + return $permittedRule; }, ['request', 'dbForPlatform', 'project', 'authorization']); /** From 3a0dc60a4cc638012338946a76e3b48bc9ce16ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:36:34 +0100 Subject: [PATCH 02/23] Add test; fix implementation; pr reviews --- app/init/resources.php | 90 +++++++++---------- .../Projects/ProjectsConsoleClientTest.php | 85 ++++++++++++++++++ 2 files changed, 126 insertions(+), 49 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 9227b0fe99..9fdfdedeba 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -198,15 +198,26 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje } $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST); + $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST); + + $hostname = $originHostname; + if (empty($hostname)) { + $hostname = $refererHostname; + } /* Add request hostname for preflight requests */ if ($request->getMethod() === 'OPTIONS') { - $allowed[] = $originHostname; + $allowed[] = $hostname; } - /* Allow the request origin if a dev key or rule is found */ - if ((!$rule->isEmpty() || !$devKey->isEmpty()) && !empty($originHostname)) { - $allowed[] = $originHostname; + /* Allow the request origin of rule */ + if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) { + $allowed[] = $rule->getAttribute('domain', ''); + } + + /* Allow the request origin if a dev key is found */ + if (!$devKey->isEmpty() && !empty($hostname)) { + $allowed[] = $hostname; } return array_unique($allowed); @@ -236,68 +247,49 @@ Http::setResource('allowedSchemes', function (Document $project) { * Rule associated with a request origin. */ Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) { - $domains = []; + $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); - $originDomain = \parse_url($request->getOrigin(), PHP_URL_HOST); - if (!empty($originDomain)) { - $domains[] = $originDomain; + if (empty($domain)) { + $domain = \parse_url($request->getReferer(), PHP_URL_HOST); } - $refererDomain = \parse_url($request->getReferer(), PHP_URL_HOST); - if (!empty($refererDomain)) { - $domains[] = $refererDomain; - } - - if (\count($domains) === 0) { + if (empty($domain)) { return new Document(); } - $permittedRule = null; - - foreach ($domains as $domain) { - // TODO: (@Meldiron) Remove after 1.7.x migration - $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; - $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { - if ($isMd5) { - return $dbForPlatform->getDocument('rules', md5($domain)); - } - - return $dbForPlatform->findOne('rules', [ - Query::equal('domain', [$domain]), - ]) ?? new Document(); - }); - - if ($rule->isEmpty()) { - continue; + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); } - $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); - // Temporary implementation until custom wildcard domains are an official feature - // Allow trusted projects; Used for Console (website) previews - if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { - $trustedProjects = []; - foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { - if (empty($trustedProject)) { - continue; - } - $trustedProjects[] = $trustedProject; - } - if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { - $permitsCurrentProject = true; + $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + + // Temporary implementation until custom wildcard domains are an official feature + // Allow trusted projects; Used for Console (website) previews + if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { + $trustedProjects = []; + foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { + if (empty($trustedProject)) { + continue; } + $trustedProjects[] = $trustedProject; } - - if ($permitsCurrentProject) { - $permittedRule = $rule; + if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { + $permitsCurrentProject = true; } } - if (\is_null($permittedRule)) { + if (!$permitsCurrentProject) { return new Document(); } - return $permittedRule; + return $rule; }, ['request', 'dbForPlatform', 'project', 'authorization']); /** diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 559ffe9f1d..460c9e365a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5116,6 +5116,91 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); } + public function testRuleOAuthRedirect(): void + { + // Prepare project + $projectId = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testRuleOAuthRedirect', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $provider = 'mock'; + $appId = '1'; + $secret = '123456'; + + // Prepare OAuth provider + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/oauth2', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'provider' => $provider, + 'appId' => $appId, + 'secret' => $secret, + 'enabled' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Prepare rule. In reality this is site rule, but for testing, API rule is enough, and faster to prepare + $domain = \uniqid() . '-with-rule.custom.localhost'; + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'domain' => $domain + ]); + + $this->assertEquals(201, $rule['headers']['status-code']); + + // Ensure unknown domain cannot be redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ], followRedirects: false); + $this->assertEquals(400, $response['headers']['status-code']); + + // Ensure rule's domain can be redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + + // Ensure unknown domain cannot be redirect URL + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'userId' => ID::unique(), + 'email' => 'user@appwrite.io', + 'url' => 'https://domain-without-rule.com', + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + // Ensure rule's domain can be redirect URL + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'userId' => ID::unique(), + 'email' => 'user@appwrite.io', + 'url' => 'https://' . $domain, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + } + /** * @group abuseEnabled */ From fd323feae8d78596c03fbebd9ee3d83acc524df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:37:35 +0100 Subject: [PATCH 03/23] Simplify diff --- app/init/resources.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/init/resources.php b/app/init/resources.php index 9fdfdedeba..8f78df1573 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -257,6 +257,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D return new Document(); } + // TODO: (@Meldiron) Remove after 1.7.x migration $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { if ($isMd5) { From 94581adfcb2085a3abf1d3345ed9735034210fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:44:21 +0100 Subject: [PATCH 04/23] Improve dev key tests --- .../Projects/ProjectsConsoleClientTest.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 460c9e365a..9c9b93d43b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5091,6 +5091,31 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://example.com' ]); $this->assertEquals(200, $response['headers']['status-code']); + + /** Ensure any hostname is allowed */ + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-dev-key' => $devKey['secret'], + 'origin' => '', + 'referer' => 'https://domain-without-rule.com' + ], [ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-dev-key' => $devKey['secret'], + 'referer' => '', + 'origin' => 'https://domain-without-rule.com' + ], [ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); /** Test hostname in Magic URL */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ @@ -5159,6 +5184,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'success' => 'https://domain-without-rule.com', 'failure' => 'https://domain-without-rule.com' @@ -5170,6 +5196,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'success' => 'https://' . $domain, 'failure' => 'https://' . $domain @@ -5181,6 +5208,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'userId' => ID::unique(), 'email' => 'user@appwrite.io', @@ -5193,6 +5221,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'userId' => ID::unique(), 'email' => 'user@appwrite.io', From 801707c4072bc90c2a20b4a5b9924d9379252054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:47:47 +0100 Subject: [PATCH 05/23] Linter fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 9c9b93d43b..e2e5621662 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5091,7 +5091,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://example.com' ]); $this->assertEquals(200, $response['headers']['status-code']); - + /** Ensure any hostname is allowed */ $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ 'content-type' => 'application/json', @@ -5104,7 +5104,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); - + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From 494ce5fc6a5d7f58a9ef8fd28afea66e12c055bd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 9 Feb 2026 13:14:28 +0530 Subject: [PATCH 06/23] chore: release cli 13.3.1 --- app/config/sdks.php | 2 +- composer.lock | 48 +++++++++++++++++++------------------- docs/sdks/cli/CHANGELOG.md | 4 ++++ 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/app/config/sdks.php b/app/config/sdks.php index de69acb984..3476e9d224 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -227,7 +227,7 @@ return [ [ 'key' => 'cli', 'name' => 'Command Line', - 'version' => '13.3.0', + 'version' => '13.3.1', 'url' => 'https://github.com/appwrite/sdk-for-cli', 'package' => 'https://www.npmjs.com/package/appwrite-cli', 'enabled' => true, diff --git a/composer.lock b/composer.lock index 6041a4984a..ce40ffa7a9 100644 --- a/composer.lock +++ b/composer.lock @@ -216,16 +216,16 @@ }, { "name": "brick/math", - "version": "0.14.6", + "version": "0.14.7", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3" + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", - "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50", + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50", "shasum": "" }, "require": { @@ -264,7 +264,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.6" + "source": "https://github.com/brick/math/tree/0.14.7" }, "funding": [ { @@ -272,7 +272,7 @@ "type": "github" } ], - "time": "2026-02-05T07:59:58+00:00" + "time": "2026-02-07T10:57:35+00:00" }, { "name": "chillerlan/php-qrcode", @@ -3795,16 +3795,16 @@ }, { "name": "utopia-php/database", - "version": "5.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "2783f07e74ddd86dda6e711d79212dd34158540d" + "reference": "aa80f86f5bf3f0d8c13abd3213bf1649f542d366" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/2783f07e74ddd86dda6e711d79212dd34158540d", - "reference": "2783f07e74ddd86dda6e711d79212dd34158540d", + "url": "https://api.github.com/repos/utopia-php/database/zipball/aa80f86f5bf3f0d8c13abd3213bf1649f542d366", + "reference": "aa80f86f5bf3f0d8c13abd3213bf1649f542d366", "shasum": "" }, "require": { @@ -3847,9 +3847,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.0.1" + "source": "https://github.com/utopia-php/database/tree/5.0.2" }, - "time": "2026-02-03T10:31:12+00:00" + "time": "2026-02-08T05:23:42+00:00" }, { "name": "utopia-php/detector", @@ -4003,16 +4003,16 @@ }, { "name": "utopia-php/domains", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "ecac82392e83d4a8ede76c3c94258d868d82f709" + "reference": "20b6a6868234d766fef35a47814dccc66906c2af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/ecac82392e83d4a8ede76c3c94258d868d82f709", - "reference": "ecac82392e83d4a8ede76c3c94258d868d82f709", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/20b6a6868234d766fef35a47814dccc66906c2af", + "reference": "20b6a6868234d766fef35a47814dccc66906c2af", "shasum": "" }, "require": { @@ -4059,9 +4059,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/1.0.0" + "source": "https://github.com/utopia-php/domains/tree/1.0.1" }, - "time": "2026-01-30T06:15:50+00:00" + "time": "2026-02-06T12:45:12+00:00" }, { "name": "utopia-php/dsn", @@ -5488,16 +5488,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.8.25", + "version": "1.8.26", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "5c75fda3410fe97387ef8e1920b583ef48849d7d" + "reference": "ce65854069a1af8ef0757650da5848168cca5f02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/5c75fda3410fe97387ef8e1920b583ef48849d7d", - "reference": "5c75fda3410fe97387ef8e1920b583ef48849d7d", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/ce65854069a1af8ef0757650da5848168cca5f02", + "reference": "ce65854069a1af8ef0757650da5848168cca5f02", "shasum": "" }, "require": { @@ -5533,9 +5533,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.8.25" + "source": "https://github.com/appwrite/sdk-generator/tree/1.8.26" }, - "time": "2026-02-05T09:40:07+00:00" + "time": "2026-02-08T07:41:27+00:00" }, { "name": "doctrine/annotations", diff --git a/docs/sdks/cli/CHANGELOG.md b/docs/sdks/cli/CHANGELOG.md index b22a2ecaec..1e8b2177c0 100644 --- a/docs/sdks/cli/CHANGELOG.md +++ b/docs/sdks/cli/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 13.3.1 + +- Fix generated TS imports to auto-detect ESM vs non-ESM + ## 13.3.0 - Support type generation for text/varchar/mediumtext/longtext attributes From f7ab8e46d3be011d5785f8171441ee7faf2a6ca8 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:45:41 +0530 Subject: [PATCH 07/23] Improve certificate dns failure logs (#11268) * Better logs for DNS failures * some more * appwrite exception * type * refactor * tiny * better logs --- .../Platform/Workers/Certificates.php | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index a726647ec4..cbfa865e88 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -177,7 +177,10 @@ class Certificates extends Action Console::success('Domain verification succeeded.'); } catch (AppwriteException $err) { Console::warning('Domain verification failed: ' . $err->getMessage()); - $rule->setAttribute('logs', $err->getMessage()); + $date = \date('H:i:s'); + $logs = "\033[90m[{$date}] \033[31mDNS verification failed: \033[0m\n"; + $logs .= \mb_strcut($err->getMessage(), 0, 500000); // Limit to 500kb + $rule->setAttribute('logs', $logs); } finally { // Update rule and emit events $this->updateRuleAndSendEvents($rule, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime); @@ -474,11 +477,20 @@ class Certificates extends Action { $mainDomain = $validationDomain ?? $this->getMainDomain(); $isMainDomain = !isset($mainDomain) || $domain->get() === $mainDomain; - if (!$isMainDomain) { - $this->verifyRule($rule, $log); - } else { + + if ($isMainDomain) { // Main domain validation // TODO: Would be awesome to check A/AAAA record here. Maybe dry run? + return; + } + + try { + $this->verifyRule($rule, $log); + } catch (AppwriteException $err) { + $msg = $err->getMessage() . "\n"; + $msg .= "Verify your DNS records are correctly configured and try again.\n"; + $msg .= "If they're correct and it still fails, please retry after sometime. DNS records can take up to 48 hours to propagate.\n"; + throw new AppwriteException($err->getType(), $msg); } } From 353b7f2a497cfdf938816414236df92e65082fdf Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:55:37 +0530 Subject: [PATCH 08/23] Move VCS auth & callback APIs to Modules (#11274) * Move VCS auth & callback APIs to Modules * lint * rename --- app/controllers/api/vcs.php | 193 ------------------ .../Modules/VCS/Http/GitHub/Authorize/Get.php | 90 ++++++++ .../Modules/VCS/Http/GitHub/Callback/Get.php | 182 +++++++++++++++++ .../Platform/Modules/VCS/Services/Http.php | 6 + 4 files changed, 278 insertions(+), 193 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php create mode 100644 src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index 6571032a62..bff12b00d2 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -1,19 +1,15 @@ desc('Create GitHub app installation') - ->groups(['api', 'vcs']) - ->label('scope', 'vcs.read') - ->label('error', __DIR__ . '/../../views/general/error.phtml') - ->label('sdk', new Method( - namespace: 'vcs', - group: 'installations', - name: 'createGitHubInstallation', - description: '/docs/references/vcs/create-github-installation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_MOVED_PERMANENTLY, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::HTML, - type: MethodType::WEBAUTH, - hide: true, - )) - ->param('success', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a successful installation attempt.', true, ['redirectValidator']) - ->param('failure', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a failed installation attempt.', true, ['redirectValidator']) - ->inject('response') - ->inject('project') - ->inject('platform') - ->action(function (string $success, string $failure, Response $response, Document $project, array $platform) { - $state = \json_encode([ - 'projectId' => $project->getId(), - 'success' => $success, - 'failure' => $failure, - ]); - - $appName = System::getEnv('_APP_VCS_GITHUB_APP_NAME'); - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $hostname = $platform['consoleHostname'] ?? ''; - - if (empty($appName)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'GitHub App name is not configured. Please configure VCS (Version Control System) variables in .env file.'); - } - - $url = "https://github.com/apps/$appName/installations/new?" . \http_build_query([ - 'state' => $state, - 'redirect_uri' => $protocol . '://' . $hostname . "/v1/vcs/github/callback" - ]); - - $response - ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - ->addHeader('Pragma', 'no-cache') - ->redirect($url); - }); - -Http::get('/v1/vcs/github/callback') - ->desc('Get installation and authorization from GitHub app') - ->groups(['api', 'vcs']) - ->label('scope', 'public') - ->label('error', __DIR__ . '/../../views/general/error.phtml') - ->param('installation_id', '', new Text(256, 0), 'GitHub installation ID', true) - ->param('setup_action', '', new Text(256, 0), 'GitHub setup action type', true) - ->param('state', '', new Text(2048), 'GitHub state. Contains info sent when starting authorization flow.', true) - ->param('code', '', new Text(2048, 0), 'OAuth2 code. This is a temporary code that the will be later exchanged for an access token.', true) - ->inject('gitHub') - ->inject('user') - ->inject('project') - ->inject('response') - ->inject('dbForPlatform') - ->inject('platform') - ->action(function (string $providerInstallationId, string $setupAction, string $state, string $code, GitHub $github, Document $user, Document $project, Response $response, Database $dbForPlatform, array $platform) { - if (empty($state)) { - $error = 'Installation requests from organisation members for the Appwrite GitHub App are currently unsupported. To proceed with the installation, login to the Appwrite Console and install the GitHub App.'; - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); - } - - $state = \json_decode($state, true); - $projectId = $state['projectId'] ?? ''; - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - $error = 'Project with the ID from state could not be found.'; - - if (!empty($redirectFailure)) { - $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; - return $response - ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - ->addHeader('Pragma', 'no-cache') - ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); - } - - throw new Exception(Exception::PROJECT_NOT_FOUND, $error); - } - - $region = $project->getAttribute('region', 'default'); - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; - $hostname = $platform['consoleHostname'] ?? ''; - - $defaultState = [ - 'success' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations", - 'failure' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations", - ]; - - $state = \array_merge($defaultState, $state ?? []); - - $redirectSuccess = $state['success'] ?? ''; - $redirectFailure = $state['failure'] ?? ''; - - // Create / Update installation - if (!empty($providerInstallationId)) { - $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) ?? ''; - - $projectInternalId = $project->getSequence(); - - $installation = $dbForPlatform->findOne('installations', [ - Query::equal('providerInstallationId', [$providerInstallationId]), - Query::equal('projectInternalId', [$projectInternalId]) - ]); - - $personal = false; - $refreshToken = null; - $accessToken = null; - $accessTokenExpiry = null; - - 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) ?? ''; - $accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code))); - - $personalSlug = $oauth2->getUserSlug($accessToken) ?? ''; - $personal = $personalSlug === $owner; - } - - if ($installation->isEmpty()) { - $teamId = $project->getAttribute('teamId', ''); - - $installation = new Document([ - '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], - 'providerInstallationId' => $providerInstallationId, - 'projectId' => $projectId, - 'projectInternalId' => $projectInternalId, - 'provider' => 'github', - 'organization' => $owner, - 'personal' => $personal, - 'personalRefreshToken' => $refreshToken, - 'personalAccessToken' => $accessToken, - 'personalAccessTokenExpiry' => $accessTokenExpiry, - ]); - - $installation = $dbForPlatform->createDocument('installations', $installation); - } else { - $installation = $installation - ->setAttribute('organization', $owner) - ->setAttribute('personal', $personal) - ->setAttribute('personalRefreshToken', $refreshToken) - ->setAttribute('personalAccessToken', $accessToken) - ->setAttribute('personalAccessTokenExpiry', $accessTokenExpiry); - $installation = $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); - } - } else { - $error = 'Installation of the Appwrite GitHub App on organization accounts is restricted to organization owners. As a member of the organization, you do not have the necessary permissions to install this GitHub App. Please contact the organization owner to create the installation from the Appwrite console.'; - - if (!empty($redirectFailure)) { - $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; - return $response - ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - ->addHeader('Pragma', 'no-cache') - ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); - } - - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); - } - - $response - ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - ->addHeader('Pragma', 'no-cache') - ->redirect($redirectSuccess); - }); - Http::post('/v1/vcs/github/events') ->desc('Create event') ->groups(['api', 'vcs']) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php new file mode 100644 index 0000000000..5db6cb6e43 --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/github/authorize') + ->desc('Create GitHub app installation') + ->groups(['api', 'vcs']) + ->label('scope', 'vcs.read') + ->label('error', __DIR__ . '/../../views/general/error.phtml') + ->label('sdk', new Method( + namespace: 'vcs', + group: 'installations', + name: 'createGitHubInstallation', + description: '/docs/references/vcs/create-github-installation.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_MOVED_PERMANENTLY, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::HTML, + type: MethodType::WEBAUTH, + hide: true, + )) + ->param('success', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a successful installation attempt.', true, ['redirectValidator']) + ->param('failure', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect back to console after a failed installation attempt.', true, ['redirectValidator']) + ->inject('response') + ->inject('project') + ->inject('platform') + ->callback($this->action(...)); + } + + public function action( + string $success, + string $failure, + Response $response, + Document $project, + array $platform + ) { + $state = \json_encode([ + 'projectId' => $project->getId(), + 'success' => $success, + 'failure' => $failure, + ]); + + $appName = System::getEnv('_APP_VCS_GITHUB_APP_NAME'); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $hostname = $platform['consoleHostname'] ?? ''; + + if (empty($appName)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'GitHub App name is not configured. Please configure VCS (Version Control System) variables in .env file.'); + } + + $url = "https://github.com/apps/$appName/installations/new?" . \http_build_query([ + 'state' => $state, + 'redirect_uri' => $protocol . '://' . $hostname . "/v1/vcs/github/callback" + ]); + + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($url); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php new file mode 100644 index 0000000000..535f26e0cd --- /dev/null +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -0,0 +1,182 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vcs/github/callback') + ->desc('Get installation and authorization from GitHub app') + ->groups(['api', 'vcs']) + ->label('scope', 'public') + ->label('error', __DIR__ . '/../../views/general/error.phtml') + ->param('installation_id', '', new Text(256, 0), 'GitHub installation ID', true) + ->param('setup_action', '', new Text(256, 0), 'GitHub setup action type', true) + ->param('state', '', new Text(2048), 'GitHub state. Contains info sent when starting authorization flow.', true) + ->param('code', '', new Text(2048, 0), 'OAuth2 code. This is a temporary code that the will be later exchanged for an access token.', true) + ->inject('gitHub') + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('platform') + ->callback($this->action(...)); + } + + public function action( + string $providerInstallationId, + string $setupAction, + string $state, + string $code, + GitHub $github, + Document $project, + Response $response, + Database $dbForPlatform, + array $platform + ) { + if (empty($state)) { + $error = 'Installation requests from organisation members for the Appwrite GitHub App are currently unsupported. To proceed with the installation, login to the Appwrite Console and install the GitHub App.'; + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); + } + + $state = \json_decode($state, true); + $projectId = $state['projectId'] ?? ''; + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + $error = 'Project with the ID from state could not be found.'; + + if (!empty($redirectFailure)) { + $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; + return $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); + } + + throw new Exception(Exception::PROJECT_NOT_FOUND, $error); + } + + $region = $project->getAttribute('region', 'default'); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; + $hostname = $platform['consoleHostname'] ?? ''; + + $defaultState = [ + 'success' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations", + 'failure' => $protocol . '://' . $hostname . "/console/project-$region-$projectId/settings/git-installations", + ]; + + $state = \array_merge($defaultState, $state ?? []); + + $redirectSuccess = $state['success'] ?? ''; + $redirectFailure = $state['failure'] ?? ''; + + // Create / Update installation + if (!empty($providerInstallationId)) { + $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) ?? ''; + + $projectInternalId = $project->getSequence(); + + $installation = $dbForPlatform->findOne('installations', [ + Query::equal('providerInstallationId', [$providerInstallationId]), + Query::equal('projectInternalId', [$projectInternalId]) + ]); + + $personal = false; + $refreshToken = null; + $accessToken = null; + $accessTokenExpiry = null; + + 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) ?? ''; + $accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code))); + + $personalSlug = $oauth2->getUserSlug($accessToken) ?? ''; + $personal = $personalSlug === $owner; + } + + if ($installation->isEmpty()) { + $teamId = $project->getAttribute('teamId', ''); + + $installation = new Document([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::team(ID::custom($teamId))), + Permission::update(Role::team(ID::custom($teamId), 'owner')), + Permission::update(Role::team(ID::custom($teamId), 'developer')), + Permission::delete(Role::team(ID::custom($teamId), 'owner')), + Permission::delete(Role::team(ID::custom($teamId), 'developer')), + ], + 'providerInstallationId' => $providerInstallationId, + 'projectId' => $projectId, + 'projectInternalId' => $projectInternalId, + 'provider' => 'github', + 'organization' => $owner, + 'personal' => $personal, + 'personalRefreshToken' => $refreshToken, + 'personalAccessToken' => $accessToken, + 'personalAccessTokenExpiry' => $accessTokenExpiry, + ]); + + $installation = $dbForPlatform->createDocument('installations', $installation); + } else { + $installation = $installation + ->setAttribute('organization', $owner) + ->setAttribute('personal', $personal) + ->setAttribute('personalRefreshToken', $refreshToken) + ->setAttribute('personalAccessToken', $accessToken) + ->setAttribute('personalAccessTokenExpiry', $accessTokenExpiry); + $installation = $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); + } + } else { + $error = 'Installation of the Appwrite GitHub App on organization accounts is restricted to organization owners. As a member of the organization, you do not have the necessary permissions to install this GitHub App. Please contact the organization owner to create the installation from the Appwrite console.'; + + if (!empty($redirectFailure)) { + $separator = \str_contains($redirectFailure, '?') ? '&' : ':'; + return $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirectFailure . $separator . \http_build_query(['error' => $error])); + } + + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $error); + } + + $response + ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->addHeader('Pragma', 'no-cache') + ->redirect($redirectSuccess); + } +} diff --git a/src/Appwrite/Platform/Modules/VCS/Services/Http.php b/src/Appwrite/Platform/Modules/VCS/Services/Http.php index 11e26be0d5..8bd2314f9e 100644 --- a/src/Appwrite/Platform/Modules/VCS/Services/Http.php +++ b/src/Appwrite/Platform/Modules/VCS/Services/Http.php @@ -2,6 +2,8 @@ namespace Appwrite\Platform\Modules\VCS\Services; +use Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\Get as GetGitHubAuthorize; +use Appwrite\Platform\Modules\VCS\Http\GitHub\Callback\Get as GetGitHubCallback; use Appwrite\Platform\Modules\VCS\Http\Installations\Delete as DeleteInstallation; use Appwrite\Platform\Modules\VCS\Http\Installations\Get as GetInstallation; use Appwrite\Platform\Modules\VCS\Http\Installations\Repositories\Branches\XList as ListRepositoryBranches; @@ -19,6 +21,10 @@ class Http extends Service { $this->type = Service::TYPE_HTTP; + // GitHub Authorization & Callback + $this->addAction(GetGitHubAuthorize::getName(), new GetGitHubAuthorize()); + $this->addAction(GetGitHubCallback::getName(), new GetGitHubCallback()); + // Installations $this->addAction(GetInstallation::getName(), new GetInstallation()); $this->addAction(ListInstallations::getName(), new ListInstallations()); From e13f4c8545d6dd3bfb0e15446d0e604d95b9bcde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:26:33 +0100 Subject: [PATCH 09/23] Fix VCS template flow --- src/Appwrite/Platform/Modules/Compute/Base.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index db0cfe7daf..e69cfd2fdc 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -109,6 +109,12 @@ class Base extends Action } catch (\Throwable $error) { // Ignore; deployment can continue } + } else { + // Fallback till we have tag support here + // Goal is to set providerBranch, so build worker knows what to clone as base + // Without this, clone command would be cloning empty branch, and failing + $providerBranch = empty($reference) ? $function->getAttribute('providerBranch', 'main') : $reference; + $branchUrl = "https://github.com/$owner/$repositoryName/tree/$providerBranch"; } $repositoryUrl = "https://github.com/$owner/$repositoryName"; From 96e1221a5f76593f82a462e0039410d38731f30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:27:31 +0100 Subject: [PATCH 10/23] Fix implementation --- src/Appwrite/Platform/Modules/Compute/Base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index e69cfd2fdc..ad467b5488 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -113,7 +113,7 @@ class Base extends Action // Fallback till we have tag support here // Goal is to set providerBranch, so build worker knows what to clone as base // Without this, clone command would be cloning empty branch, and failing - $providerBranch = empty($reference) ? $function->getAttribute('providerBranch', 'main') : $reference; + $providerBranch = $function->getAttribute('providerBranch', 'main'); $branchUrl = "https://github.com/$owner/$repositoryName/tree/$providerBranch"; } From e09dd98f360b680bbcbaea24101dc63de3f21740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:30:46 +0100 Subject: [PATCH 11/23] Fix site vcs deployment bug too --- src/Appwrite/Platform/Modules/Compute/Base.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index ad467b5488..45c839df3b 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -205,6 +205,12 @@ class Base extends Action } catch (\Throwable $error) { // Ignore; deployment can continue } + } else { + // Fallback till we have tag support here + // Goal is to set providerBranch, so build worker knows what to clone as base + // Without this, clone command would be cloning empty branch, and failing + $providerBranch = $site->getAttribute('providerBranch', 'main'); + $branchUrl = "https://github.com/$owner/$repositoryName/tree/$providerBranch"; } $repositoryUrl = "https://github.com/$owner/$repositoryName"; From 36c87d109a4d9a7d5f79056a38fd938197c4fe7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:45:18 +0100 Subject: [PATCH 12/23] Fix rule oauth flow --- app/controllers/api/account.php | 20 ++++++++++++++++++-- src/Appwrite/Network/Validator/Origin.php | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index bc84ae7ef6..8d819d429d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -64,7 +64,6 @@ use Utopia\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; -use Utopia\Validator; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -1469,13 +1468,14 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('devKey') ->inject('user') ->inject('dbForProject') + ->inject('dbForPlatform') ->inject('geodb') ->inject('queueForEvents') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') ->inject('authorization') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Redirect $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1512,6 +1512,22 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $state = $defaultState; } + // Allow redirect to rule URL if related to project + $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', [ + parse_url($state['success'], PHP_URL_HOST), + parse_url($state['failure'], PHP_URL_HOST) + ]), + Query::equal('projectIntenralId', [$project->getSequence()]), + Query::limit(2) + ])); + + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule['domain']; + $redirectValidator->setAllowedHostnames($allowedHostnames); + } + if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { throw new Exception(Exception::PROJECT_INVALID_SUCCESS_URL); } diff --git a/src/Appwrite/Network/Validator/Origin.php b/src/Appwrite/Network/Validator/Origin.php index 02d5d8e83d..2f76aa2f86 100644 --- a/src/Appwrite/Network/Validator/Origin.php +++ b/src/Appwrite/Network/Validator/Origin.php @@ -22,6 +22,27 @@ class Origin extends Validator { } + public function setAllowedHostnames(array $allowedHostnames): self + { + $this->allowedHostnames = $allowedHostnames; + return $this; + } + + public function setAllowedSchemes(array $allowedSchemes): self + { + $this->allowedSchemes = $allowedSchemes; + return $this; + } + + public function getAllowedHostnames(): array + { + return $this->allowedHostnames; + } + + public function getAllowedSchemes(): array + { + return $this->allowedSchemes; + } /** * Check if Origin is valid. From 074ffad82624fffabec966075fce86e6cbb96287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:46:15 +0100 Subject: [PATCH 13/23] Improve origin unit tests --- tests/unit/Network/Validators/OriginTest.php | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/unit/Network/Validators/OriginTest.php b/tests/unit/Network/Validators/OriginTest.php index a4c235f755..aa3ab65e5a 100644 --- a/tests/unit/Network/Validators/OriginTest.php +++ b/tests/unit/Network/Validators/OriginTest.php @@ -74,4 +74,60 @@ class OriginTest extends TestCase $this->assertEquals(false, $validator->isValid('random-scheme://localhost')); $this->assertEquals('Invalid Scheme. The scheme used (random-scheme) in the Origin (random-scheme://localhost) is not supported. If you are using a custom scheme, please change it to `appwrite-callback-`', $validator->getDescription()); } + + public function testGetAllowedHostnames(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io', 'localhost'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(['appwrite.io', 'localhost'], $validator->getAllowedHostnames()); + } + + public function testGetAllowedSchemes(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp', 'appwrite-callback-123'] + ); + + $this->assertEquals(['exp', 'appwrite-callback-123'], $validator->getAllowedSchemes()); + } + + public function testSetAllowedHostnames(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(true, $validator->isValid('https://appwrite.io')); + $this->assertEquals(false, $validator->isValid('https://example.com')); + + $result = $validator->setAllowedHostnames(['example.com']); + + $this->assertSame($validator, $result); + $this->assertEquals(['example.com'], $validator->getAllowedHostnames()); + $this->assertEquals(true, $validator->isValid('https://example.com')); + $this->assertEquals(false, $validator->isValid('https://appwrite.io')); + } + + public function testSetAllowedSchemes(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(true, $validator->isValid('exp://')); + $this->assertEquals(false, $validator->isValid('appwrite-callback-456://')); + + $result = $validator->setAllowedSchemes(['appwrite-callback-456']); + + $this->assertSame($validator, $result); + $this->assertEquals(['appwrite-callback-456'], $validator->getAllowedSchemes()); + $this->assertEquals(true, $validator->isValid('appwrite-callback-456://')); + $this->assertEquals(false, $validator->isValid('exp://')); + } } From 525b929e54b668227b94b00d60574ca820ef9bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:57:19 +0100 Subject: [PATCH 14/23] Fix implementation, add tests --- app/controllers/api/account.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 8d819d429d..afb45dbfb9 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1518,7 +1518,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') parse_url($state['success'], PHP_URL_HOST), parse_url($state['failure'], PHP_URL_HOST) ]), - Query::equal('projectIntenralId', [$project->getSequence()]), + Query::equal('projectInternalId', [$project->getSequence()]), Query::limit(2) ])); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e2e5621662..abdcbcee24 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5190,6 +5190,25 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); + + // Also ensure final step blocks unknown redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ]), + 'error' => '', + 'errorDescription' => '', + ], followRedirects: false); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString('project_invalid_success_url', $response['body']); // Ensure rule's domain can be redirect URL $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ @@ -5202,6 +5221,25 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); + + // Also ensure final step allows redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain + ]), + 'error' => '', + 'errorDescription' => '', + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('https://' . $domain, $response['headers']['location']); // Ensure unknown domain cannot be redirect URL $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ From 615aff07143feb09903e350f6c37b02ec146fe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:34:05 +0100 Subject: [PATCH 15/23] Allow custom ID for API keys --- app/controllers/api/projects.php | 6 ++- tests/e2e/Scopes/ProjectCustom.php | 2 + .../Projects/ProjectsConsoleClientTest.php | 44 ++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d61340cfff..23ec2f51c4 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -14,6 +14,7 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; +use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; @@ -1094,12 +1095,13 @@ Http::post('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('keyId', '', new CustomId(), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1108,7 +1110,7 @@ Http::post('/v1/projects/:projectId/keys') } $key = new Document([ - '$id' => ID::unique(), + '$id' => $keyId, '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 52c53016d6..1859d551a4 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -60,6 +60,7 @@ trait ProjectCustom 'cookie' => 'a_session_console=' . $this->getRoot()['session'], 'x-appwrite-project' => 'console', ], [ + 'keyId' => ID::unique(), 'name' => 'Demo Project Key', 'scopes' => [ 'users.read', @@ -194,6 +195,7 @@ trait ProjectCustom 'cookie' => 'a_session_console=' . $this->getRoot()['session'], 'x-appwrite-project' => 'console', ], [ + 'keyId' => ID::unique(), 'name' => 'Demo Project Key', 'scopes' => $scopes, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e2e5621662..d12ad35d9c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2774,6 +2774,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['functions.read', 'teams.write'], ]); @@ -3123,6 +3124,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['teams.read', 'teams.write'], ]); @@ -3137,6 +3139,36 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']['sdks']); $this->assertArrayHasKey('accessedAt', $response['body']); $this->assertEmpty($response['body']['accessedAt']); + + /** + * Test for SUCCESS without key ID + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Custom', + 'scopes' => ['teams.read', 'teams.write'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + /** + * Test for SUCCESS with custom ID + */ + $customKeyId = 'key-with-custom-id'; + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => $customKeyId, + 'name' => 'Key Custom', + 'scopes' => ['teams.read', 'teams.write'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($customKeyId, $response['body']['$id']); $data = array_merge($data, [ 'keyId' => $response['body']['$id'], @@ -3150,6 +3182,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['unknown'], ]); @@ -3174,7 +3207,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(1, $response['body']['total']); + $this->assertEquals(3, $response['body']['total']); /** * Test for FAILURE @@ -3200,7 +3233,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($keyId, $response['body']['$id']); - $this->assertEquals('Key Test', $response['body']['name']); + $this->assertEquals('Key Custom', $response['body']['name']); $this->assertContains('teams.read', $response['body']['scopes']); $this->assertContains('teams.write', $response['body']['scopes']); $this->assertCount(2, $response['body']['scopes']); @@ -3240,6 +3273,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['users.write'], 'expire' => DateTime::addSeconds(new \DateTime(), 3600), @@ -3260,6 +3294,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['health.read'], 'expire' => null, @@ -3282,6 +3317,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['health.read'], 'expire' => DateTime::addSeconds(new \DateTime(), -3600), @@ -3323,6 +3359,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['teams.read'], 'expire' => DateTime::addSeconds(new \DateTime(), 3600), @@ -3355,6 +3392,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['health.read'], 'expire' => DateTime::addSeconds(new \DateTime(), 3600), @@ -4364,6 +4402,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['users.read', 'users.write'], ]); @@ -4384,6 +4423,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['users.read', 'users.write'], ]); From 9b762dde40baf629937ab792bd1e0ff6179e53ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:34:36 +0100 Subject: [PATCH 16/23] formatting fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index d12ad35d9c..25a29f9844 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3139,7 +3139,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']['sdks']); $this->assertArrayHasKey('accessedAt', $response['body']); $this->assertEmpty($response['body']['accessedAt']); - + /** * Test for SUCCESS without key ID */ From 40ab50ec9de23fd28c48c9bdf1784fab83f84445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:34:52 +0100 Subject: [PATCH 17/23] formatting fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index abdcbcee24..c406632a2d 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5190,7 +5190,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); - + // Also ensure final step blocks unknown redirect URL $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', @@ -5221,7 +5221,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); - + // Also ensure final step allows redirect URL $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', @@ -5232,8 +5232,8 @@ class ProjectsConsoleClientTest extends Scope ], [ 'code' => 'any-code', 'state' => \json_encode([ - 'success' => 'https://' . $domain, - 'failure' => 'https://' . $domain + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain ]), 'error' => '', 'errorDescription' => '', From 96e85c0bab0c0d9719913cf90a750e572e338370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:35:47 +0100 Subject: [PATCH 18/23] AI pr review --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index c406632a2d..74d1aa9580 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5195,7 +5195,6 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'referer' => 'https://' . $domain, 'origin' => '', 'referer' => 'https://mockserver.com', ], [ @@ -5226,7 +5225,6 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'referer' => 'https://' . $domain, 'origin' => '', 'referer' => 'https://mockserver.com', ], [ From 7bf5f2d36074989d30c0f999f1cd0572fe014b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 15:55:35 +0100 Subject: [PATCH 19/23] Fix bug 5xx error --- .env | 2 +- app/controllers/api/account.php | 30 +++++++++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.env b/.env index 7ac8fc25ef..c190e29c0b 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ _APP_EMAIL_SECURITY=security@appwrite.io _APP_EMAIL_CERTIFICATES=certificates@appwrite.io _APP_SYSTEM_RESPONSE_FORMAT= _APP_CUSTOM_DOMAIN_DENY_LIST= -_APP_OPTIONS_ABUSE=disabled +_APP_OPTIONS_ABUSE=enabled _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index afb45dbfb9..1e2bb5aee0 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -64,6 +64,7 @@ use Utopia\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; +use Utopia\Validator; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -1475,7 +1476,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForPassword') ->inject('proofForToken') ->inject('authorization') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Redirect $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1513,19 +1514,22 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // Allow redirect to rule URL if related to project - $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ - Query::equal('domain', [ - parse_url($state['success'], PHP_URL_HOST), - parse_url($state['failure'], PHP_URL_HOST) - ]), - Query::equal('projectInternalId', [$project->getSequence()]), - Query::limit(2) - ])); + //Check if $redirectValidator is instance of Redirect class + if ($redirectValidator instanceof Redirect) { + $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', [ + parse_url($state['success'], PHP_URL_HOST), + parse_url($state['failure'], PHP_URL_HOST) + ]), + Query::equal('projectInternalId', [$project->getSequence()]), + Query::limit(2) + ])); - foreach ($rules as $rule) { - $allowedHostnames = $redirectValidator->getAllowedHostnames(); - $allowedHostnames[] = $rule['domain']; - $redirectValidator->setAllowedHostnames($allowedHostnames); + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule['domain']; + $redirectValidator->setAllowedHostnames($allowedHostnames); + } } if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { From 3dc69ba62abb6acc4b2b58722071173bc037fd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 15:55:49 +0100 Subject: [PATCH 20/23] Revert unwanted push --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index c190e29c0b..7ac8fc25ef 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ _APP_EMAIL_SECURITY=security@appwrite.io _APP_EMAIL_CERTIFICATES=certificates@appwrite.io _APP_SYSTEM_RESPONSE_FORMAT= _APP_CUSTOM_DOMAIN_DENY_LIST= -_APP_OPTIONS_ABUSE=enabled +_APP_OPTIONS_ABUSE=disabled _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled From e666dc9504e70313cceb06d78762e24556d22c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 16:42:14 +0100 Subject: [PATCH 21/23] AI review fixes --- app/controllers/api/projects.php | 4 +++- .../Projects/ProjectsConsoleClientTest.php | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 23ec2f51c4..4fa40bcbc1 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1095,13 +1095,15 @@ Http::post('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('keyId', '', new CustomId(), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + // TODO: When migrating to Platform API, mark keyId required for consistency + ->param('keyId', 'unique()', new CustomId(), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { + $keyId = $keyId == 'unique()' ? ID::unique() : $keyId; $project = $dbForPlatform->getDocument('projects', $projectId); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 25a29f9844..de67438b91 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3167,8 +3167,20 @@ class ProjectsConsoleClientTest extends Scope 'scopes' => ['teams.read', 'teams.write'], ]); + /** + * Test for SUCCESS with magic string ID + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => 'unique()', + 'name' => 'Key Custom', + 'scopes' => ['teams.read', 'teams.write'], + ]); + $this->assertEquals(201, $response['headers']['status-code']); - $this->assertEquals($customKeyId, $response['body']['$id']); + $this->assertNotEmpty($response['body']['$id']); $data = array_merge($data, [ 'keyId' => $response['body']['$id'], From c0f5fa90cb6eb977355720d0c07c7337502a507b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 16:53:18 +0100 Subject: [PATCH 22/23] Fix AI review --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index de67438b91..2fa6c67461 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3167,6 +3167,9 @@ class ProjectsConsoleClientTest extends Scope 'scopes' => ['teams.read', 'teams.write'], ]); + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertSame($customKeyId, $response['body']['$id']); + /** * Test for SUCCESS with magic string ID */ @@ -3181,6 +3184,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); + $this->assertNotSame('unique()', $response['body']['$id']); $data = array_merge($data, [ 'keyId' => $response['body']['$id'], @@ -3219,7 +3223,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(3, $response['body']['total']); + $this->assertEquals(4, $response['body']['total']); /** * Test for FAILURE From a263afeff107dcda768c0703ff8a163e756bed6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 17:10:00 +0100 Subject: [PATCH 23/23] AI quality fixes --- app/controllers/api/account.php | 2 +- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 1e2bb5aee0..17515fe949 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1527,7 +1527,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') foreach ($rules as $rule) { $allowedHostnames = $redirectValidator->getAllowedHostnames(); - $allowedHostnames[] = $rule['domain']; + $allowedHostnames[] = $rule->getAttribute('domain', ''); $redirectValidator->setAllowedHostnames($allowedHostnames); } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 74d1aa9580..5280509967 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5204,7 +5204,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ]), 'error' => '', - 'errorDescription' => '', + 'error_description' => '', ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString('project_invalid_success_url', $response['body']); @@ -5234,7 +5234,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ]), 'error' => '', - 'errorDescription' => '', + 'error_deescription' => '', ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringContainsString('https://' . $domain, $response['headers']['location']);