diff --git a/Dockerfile b/Dockerfile index 1922a0d2b9..9a61635415 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.2.1 AS base +FROM appwrite/base:1.4.1 AS base LABEL maintainer="team@appwrite.io" diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 7f39d6db95..bd30da9aac 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -215,6 +215,16 @@ return [ 'description' => 'Access to create function executions', 'category' => 'Functions', ], + 'execution.read' => [ + 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.', + 'category' => 'Functions', + 'deprecated' => true, + ], + 'execution.write' => [ + 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.', + 'category' => 'Functions', + 'deprecated' => true, + ], // Sites 'sites.read' => [ diff --git a/app/config/templates/function.php b/app/config/templates/function.php index df3a569705..c6ac446509 100644 --- a/app/config/templates/function.php +++ b/app/config/templates/function.php @@ -79,12 +79,13 @@ return [ ...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList), ...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList), ...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList), + ...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList), ], - 'instructions' => 'For documentation and instructions check out file.', + 'instructions' => 'For documentation and instructions check out the templates repository.', 'vcsProvider' => 'github', 'providerRepositoryId' => 'templates', 'providerOwner' => 'appwrite', - 'providerVersion' => '0.2.*', + 'providerVersion' => '0.3.*', 'variables' => [], 'scopes' => ['users.read'] ], diff --git a/app/realtime.php b/app/realtime.php index 12d2a5c600..f72609dee8 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -325,14 +325,8 @@ if (!function_exists('triggerPresenceUsage')) { if (!function_exists('getQueueForEventsForProject')) { function getQueueForEventsForProject(Document $project, User $user): QueueEvent { - global $register; - - /** @var Group $pools */ - $pools = $register->get('pools'); - - $queueForEvents = new QueueEvent(new BrokerPool( - publisher: $pools->get('publisher') - )); + global $container; + $queueForEvents = $container->get('queueForEvents'); $queueForEvents->setProject($project); $queueForEvents->setUser($user); @@ -343,8 +337,6 @@ if (!function_exists('getQueueForEventsForProject')) { if (!function_exists('triggerPresenceEvent')) { function triggerPresenceEvent( - Server $server, - Realtime $realtime, Document $project, User $user, string $eventName, @@ -410,12 +402,25 @@ if (!function_exists('setPermission')) { } global $container; + $container->set('pools', function ($register) { return $register->get('pools'); }, ['register']); -$container->set('queueForRealtime', function () { - return new QueueRealtime(); -}, []); + +if (!$container->has('queueForEvents')) { + $container->set('queueForEvents', function ($pools) { + var_dump("ce"); + return new QueueEvent(new BrokerPool( + publisher: $pools->get('publisher') + )); + }, ['pools']); +} + +if (!$container->has('queueForRealtime')) { + $container->set('queueForRealtime', function () { + return new QueueRealtime(); + }, []); +} $realtime = getRealtime(); $presenceState = new PresenceState(); @@ -1292,7 +1297,7 @@ $server->onMessage(function (int $connection, string $message) use ($container, } }); -$server->onClose(function (int $connection) use ($server, $realtime, $stats, $register) { +$server->onClose(function (int $connection) use ($realtime, $stats, $register) { $projectId = null; $userId = null; $subscriptionsBeforeClose = 0; @@ -1349,7 +1354,7 @@ $server->onClose(function (int $connection) use ($server, $realtime, $stats, $re foreach ($presences as $presence) { try { - triggerPresenceEvent($server, $realtime, $project, new User([]), 'presences.[presenceId].delete', $presence); + triggerPresenceEvent($project, new User([]), 'presences.[presenceId].delete', $presence); } catch (Throwable) { // Swallow errors to avoid breaking disconnect cleanup } diff --git a/composer.json b/composer.json index 683da6f21b..236e3aa83d 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "ext-openssl": "*", "ext-zlib": "*", "ext-sockets": "*", - "appwrite/php-runtimes": "0.19.*", + "appwrite/php-runtimes": "0.20.*", "appwrite/php-clamav": "2.0.*", "utopia-php/abuse": "1.2.*", "utopia-php/agents": "1.2.*", @@ -93,7 +93,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "15.31.*", + "webonyx/graphql-php": "15.32.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index d2018fdea0..de52fe7854 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bee36b21a57e754d2b3417e72dc9599", + "content-hash": "2af4d953af2a624be8bf2f89ea27336c", "packages": [ { "name": "adhocore/jwt", @@ -161,16 +161,16 @@ }, { "name": "appwrite/php-runtimes", - "version": "0.19.5", + "version": "0.20.0", "source": { "type": "git", "url": "https://github.com/appwrite/runtimes.git", - "reference": "aa2f7760cd0493c0880209b92df812c9386b3546" + "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546", - "reference": "aa2f7760cd0493c0880209b92df812c9386b3546", + "url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3", + "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3", "shasum": "" }, "require": { @@ -210,9 +210,9 @@ ], "support": { "issues": "https://github.com/appwrite/runtimes/issues", - "source": "https://github.com/appwrite/runtimes/tree/0.19.5" + "source": "https://github.com/appwrite/runtimes/tree/0.20.0" }, - "time": "2026-04-01T01:39:23+00:00" + "time": "2026-05-01T07:47:07+00:00" }, { "name": "brick/math", @@ -3502,16 +3502,16 @@ }, { "name": "utopia-php/audit", - "version": "2.2.1", + "version": "2.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd" + "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/e3e2d6ad5c7f6377d9237df296a12eb7943892fd", - "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/90886c202e7983999e6b6a8201004d5ab61d4b57", + "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57", "shasum": "" }, "require": { @@ -3545,9 +3545,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/2.2.1" + "source": "https://github.com/utopia-php/audit/tree/2.2.2" }, - "time": "2026-02-02T10:39:25+00:00" + "time": "2026-05-04T06:48:58+00:00" }, { "name": "utopia-php/auth", @@ -5384,16 +5384,16 @@ }, { "name": "webonyx/graphql-php", - "version": "v15.31.5", + "version": "v15.32.3", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145", "shasum": "" }, "require": { @@ -5402,16 +5402,16 @@ "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", + "amphp/amp": "^2.6 || ^3", + "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.94.2", + "friendsofphp/php-cs-fixer": "3.95.1", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.46", + "phpstan/phpstan": "2.1.51", "phpstan/phpstan-phpunit": "2.0.16", "phpstan/phpstan-strict-rules": "2.0.10", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", @@ -5425,6 +5425,7 @@ "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" @@ -5447,7 +5448,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" + "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3" }, "funding": [ { @@ -5459,7 +5460,7 @@ "type": "open_collective" } ], - "time": "2026-04-11T18:06:15+00:00" + "time": "2026-04-24T13:49:35+00:00" } ], "packages-dev": [ @@ -8444,7 +8445,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8465,5 +8466,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 3004392f76..24803eeaa7 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -51,38 +51,49 @@ class Request extends UtopiaRequest if (!\is_array($methods)) { $id = $methods->getNamespace() . '.' . $methods->getMethodName(); + } else { + $matched = null; + foreach ($methods as $method) { + /** @var Method|null $method */ + if ($method === null) { + continue; + } + + // Find the method that matches the parameters passed + $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters()); + $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames); + + // No params defined, or all params are valid + if (empty($methodParamNames) || empty($invalidParams)) { + $matched = $method; + break; + } + } + + $id = $matched !== null + ? $matched->getNamespace() . '.' . $matched->getMethodName() + : 'unknown.unknown'; + } + + try { foreach ($this->getFilters() as $filter) { $parameters = $filter->parse($parameters, $id); } - $this->filteredParams = $parameters; - return $parameters; - } - - $matched = null; - foreach ($methods as $method) { - /** @var Method|null $method */ - if ($method === null) { - continue; + } catch (\Throwable $e) { + /* + * 4xx filter throws are user-input errors that the action layer + * revalidates and reports. Cache the raw, pre-filter parameters + * so a subsequent getParams() — e.g. when the framework builds + * arguments for an error hook — returns without re-running + * filters. Otherwise the second throw gets wrapped as + * "Error handler had an error: ..." (HTTP 500), masking the + * intended 400. + */ + $code = $e->getCode(); + if (\is_int($code) && $code >= 400 && $code < 500) { + $this->filteredParams = $parameters; } - - // Find the method that matches the parameters passed - $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters()); - $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames); - - // No params defined, or all params are valid - if (empty($methodParamNames) || empty($invalidParams)) { - $matched = $method; - break; - } - } - - $id = $matched !== null - ? $matched->getNamespace() . '.' . $matched->getMethodName() - : 'unknown.unknown'; - - // Apply filters - foreach ($this->getFilters() as $filter) { - $parameters = $filter->parse($parameters, $id); + throw $e; } $this->filteredParams = $parameters; diff --git a/tests/e2e/Services/Presence/PresenceBase.php b/tests/e2e/Services/Presence/PresenceBase.php index 9131af18dd..8f05b4144a 100644 --- a/tests/e2e/Services/Presence/PresenceBase.php +++ b/tests/e2e/Services/Presence/PresenceBase.php @@ -105,6 +105,26 @@ trait PresenceBase return $presence; } + protected function resolvePresenceForUser(string $userId, array $headers): array + { + $presence = $this->client->call( + Client::METHOD_GET, + '/presences', + $headers, + [ + 'queries' => [ + Query::equal('userId', [$userId])->toString(), + ], + ] + ); + + $this->assertEquals(200, $presence['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $presence['body']['total'] ?? 0); + $this->assertNotEmpty($presence['body']['presences'][0] ?? []); + + return $presence['body']['presences'][0]; + } + public function testUpsertAndGetPresence(): void { if ($this->getSide() === 'client') { @@ -270,16 +290,12 @@ trait PresenceBase } $projectId = $this->getProject()['$id']; - $user1 = $this->getUser(); - $headersUser1 = $this->getHeaders(false); - + $user1 = $this->getUser(true); $user2 = $this->getUser(true); - - // Avoid overwriting the cached user for the rest of the test run. - self::$user[$projectId] = $user1; - - $headersUser2 = $this->getHeaders(false); - $headersUser2['cookie'] = 'a_session_' . $projectId . '=' . $user2['session']; + $headersUser2 = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $user2['session'], + ]; $permissionsForUser2 = [ Permission::read(Role::user($user2['$id'])), @@ -430,7 +446,14 @@ trait PresenceBase ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presenceId = $upsert['body']['$id']; + $presence = $this->resolvePresenceForUser( + $upsert['body']['userId'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + $presenceId = $presence['$id']; $update = $this->client->call( Client::METHOD_PATCH, @@ -498,7 +521,14 @@ trait PresenceBase ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presenceId = $upsert['body']['$id']; + $presence = $this->resolvePresenceForUser( + $upsert['body']['userId'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + $presenceId = $presence['$id']; $delete = $this->client->call( Client::METHOD_DELETE, @@ -547,11 +577,11 @@ trait PresenceBase ] ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presence = $upsert['body']; $headers = \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); } else { $presence = $this->setupPresence([ 'status' => 'cache-update-setup', @@ -619,11 +649,11 @@ trait PresenceBase ] ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presence = $upsert['body']; $headers = \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); } else { $presence = $this->setupPresence([ 'status' => 'cache-purge-only-setup', @@ -690,11 +720,11 @@ trait PresenceBase ] ); $this->assertEquals(200, $upsert['headers']['status-code']); - $presence = $upsert['body']; $headers = \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders(false)); + $presence = $this->resolvePresenceForUser($upsert['body']['userId'], $headers); } else { $presence = $this->setupPresence([ 'status' => 'cache-delete-setup', diff --git a/tests/unit/Utopia/Request/Filters/ThrowingFilter.php b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php new file mode 100644 index 0000000000..8e02b92e39 --- /dev/null +++ b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php @@ -0,0 +1,24 @@ +calls++; + throw new \Exception($this->reason, $this->code); + } +} diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 57ebae6d1e..2247ff71f1 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -5,10 +5,12 @@ namespace Tests\Unit\Utopia; use Appwrite\SDK\Method; use Appwrite\SDK\Parameter; use Appwrite\Utopia\Request; +use Appwrite\Utopia\Request\Filter; use PHPUnit\Framework\TestCase; use Swoole\Http\Request as SwooleRequest; use Tests\Unit\Utopia\Request\Filters\First; use Tests\Unit\Utopia\Request\Filters\Second; +use Tests\Unit\Utopia\Request\Filters\ThrowingFilter; use Utopia\Http\Route; class RequestTest extends TestCase @@ -192,6 +194,109 @@ class RequestTest extends TestCase $this->assertSame('fallback', $request->getHeader('referer', 'fallback')); } + public function testGetParamsCachesRawParamsWhenFilterThrows4xx(): void + { + /* + * Regression: when a request filter throws a 4xx exception during + * Request::getParams() (e.g. RequestV20 rejecting an unparseable + * queries[]), the framework's error path calls getParams() again to + * build error-hook arguments. Without caching, that second call + * re-runs the filter and re-throws, which the framework wraps as + * "Error handler had an error: ..." (HTTP 500), masking the intended + * 400. This test pins that behavior: the first call throws (so the + * action's argument resolution aborts), but the second call returns + * the raw, pre-filter params without re-invoking filters. + */ + $filter = new ThrowingFilter(400, 'invalid input'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable $e) { + $threw = true; + $this->assertSame(400, $e->getCode()); + $this->assertSame('invalid input', $e->getMessage()); + } + $this->assertTrue($threw, 'First getParams() call must rethrow the filter exception.'); + $this->assertSame(1, $filter->calls, 'Filter ran once on the first call.'); + + // Second call: framework's error hook arg resolution. Must return raw + // params without re-invoking the filter. + $params = $this->request->getParams(); + $this->assertSame(['foo' => 'bar'], $params); + $this->assertSame(1, $filter->calls, 'Filter must not run again after a cached 4xx failure.'); + } + + public function testGetParamsDoesNotCacheRawParamsForServerError(): void + { + /* + * 5xx filter throws indicate genuine server-side problems, not + * user-input mistakes. They must keep rethrowing on every call so + * the framework's normal error handling sees the failure each time + * — caching raw params would silently swallow real bugs. + */ + $filter = new ThrowingFilter(500, 'boom'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable $e) { + $threw = true; + $this->assertSame(500, $e->getCode()); + } + $this->assertTrue($threw, "Call #$attempt must rethrow."); + $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt."); + } + } + + public function testGetParamsDoesNotCacheRawParamsForUncodedException(): void + { + // \Exception with the default code of 0 is treated as "unknown" and + // must propagate every call — same reasoning as 5xx. + $filter = new ThrowingFilter(0, 'unknown'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable) { + $threw = true; + } + $this->assertTrue($threw, "Call #$attempt must rethrow."); + $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt."); + } + } + + /** + * Helper to attach a route with a single SDK method and one filter. + */ + private function setupSingleMethodRoute(Filter $filter): void + { + $route = new Route(Request::METHOD_GET, '/single'); + $route->label('sdk', new Method( + namespace: 'namespace', + group: 'group', + name: 'method', + description: 'description', + auth: [], + responses: [], + )); + + $this->request->addHeader('EXAMPLE', 'VALUE'); + $this->request->setRoute($route); + $this->request->addFilter($filter); + } + /** * Helper to attach a route with multiple SDK methods to the request. */