diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aeae21655..494a9c1424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,7 +512,7 @@ jobs: # Services that rely on sequential test method execution (shared static state) FUNCTIONAL_FLAG="--functional" case "${{ matrix.service }}" in - Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;; + Databases|TablesDB|Functions|Realtime|GraphQL) FUNCTIONAL_FLAG="" ;; esac docker compose exec -T \ diff --git a/app/config/variables.php b/app/config/variables.php index 7a3ed13049..c834656ff4 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -872,18 +872,18 @@ return [ ], [ 'name' => '_APP_FUNCTIONS_BUILD_TIMEOUT', - 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 900 seconds.', + 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 2700 seconds.', 'introduction' => '0.13.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' ], [ 'name' => '_APP_COMPUTE_BUILD_TIMEOUT', - 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 900 seconds.', + 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 2700 seconds.', 'introduction' => '1.7.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 937380b643..9ec2479749 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -231,7 +231,7 @@ function execute( $validations = GraphQL::getStandardValidationRules(); if (System::getEnv('_APP_GRAPHQL_INTROSPECTION', 'enabled') === 'disabled') { - $validations[] = new DisableIntrospection(); + $validations[] = new DisableIntrospection(DisableIntrospection::ENABLED); } if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled') { diff --git a/composer.json b/composer.json index 4ad1ae6120..3aa6d157cf 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "14.11.*", + "webonyx/graphql-php": "15.31.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index 84febe60a8..4afe17abff 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": "4fb974e9843f6104e40396e7cad4a833", + "content-hash": "f6a87c1012b316e614258f8f57a28e48", "packages": [ { "name": "adhocore/jwt", @@ -5381,38 +5381,48 @@ }, { "name": "webonyx/graphql-php", - "version": "v14.11.10", + "version": "v15.31.5", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19" + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "php": "^7.1 || ^8" + "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.3", - "doctrine/coding-standard": "^6.0", - "nyholm/psr7": "^1.2", + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.94.2", + "mll-lab/php-cs-fixer-config": "5.13.0", + "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "0.12.82", - "phpstan/phpstan-phpunit": "0.12.18", - "phpstan/phpstan-strict-rules": "0.12.9", - "phpunit/phpunit": "^7.2 || ^8.5", - "psr/http-message": "^1.0", - "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0" + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.46", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.10", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", + "thecodingmachine/safe": "^1.3 || ^2 || ^3", + "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "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" }, @@ -5434,15 +5444,19 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10" + "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" }, "funding": [ + { + "url": "https://github.com/spawnia", + "type": "github" + }, { "url": "https://opencollective.com/webonyx-graphql-php", "type": "open_collective" } ], - "time": "2023-07-05T14:23:37+00:00" + "time": "2026-04-11T18:06:15+00:00" } ], "packages-dev": [ @@ -8449,5 +8463,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php index 076ed5c02d..585d4b09a7 100644 --- a/src/Appwrite/Bus/Listeners/Log.php +++ b/src/Appwrite/Bus/Listeners/Log.php @@ -7,6 +7,8 @@ use Appwrite\Event\Message\Execution as ExecutionMessage; use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Utopia\Bus\Listener; use Utopia\Database\Document; +use Utopia\Span\Span; +use Utopia\System\System; class Log extends Listener { @@ -30,9 +32,27 @@ class Log extends Listener public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void { + $project = new Document($event->project); + $execution = new Document($event->execution); + if ($execution->getAttribute('resourceType', '') === 'functions') { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.v1_executions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + } + $publisherForExecutions->enqueue(new ExecutionMessage( - project: new Document($event->project), - execution: new Document($event->execution), + project: $project, + execution: $execution, )); } } diff --git a/src/Appwrite/GraphQL/Promises/Adapter.php b/src/Appwrite/GraphQL/Promises/Adapter.php index 86270f2a8b..1d9cc4557f 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter.php +++ b/src/Appwrite/GraphQL/Promises/Adapter.php @@ -81,8 +81,8 @@ abstract class Adapter implements PromiseAdapter /** * Create a new promise that resolves when all passed in promises resolve. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return GQLPromise */ - abstract public function all(array $promisesOrValues): GQLPromise; + abstract public function all(iterable $promisesOrValues): GQLPromise; } diff --git a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php index efe6eb2f50..af6441ad6d 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php +++ b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php @@ -35,8 +35,12 @@ class Swoole extends Adapter return new GQLPromise($promise, $this); } - public function all(array $promisesOrValues): GQLPromise + public function all(iterable $promisesOrValues): GQLPromise { + if ($promisesOrValues instanceof \Traversable) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + return new GQLPromise(SwoolePromise::all($promisesOrValues), $this); } } diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 83342bc31d..c13ceeebd3 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -420,6 +420,8 @@ class Resolvers $lock = self::getLock($utopia); self::acquireLock($lock); + + $original = $utopia->getRoute(); try { $request = clone $request; @@ -461,6 +463,10 @@ class Resolvers $reject($e); return; } finally { + if ($original !== null) { + $utopia->setRoute($original); + } + self::releaseLock($lock); } diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php index f76b23dd7a..15bd742d1d 100644 --- a/src/Appwrite/GraphQL/Types/Assoc.php +++ b/src/Appwrite/GraphQL/Types/Assoc.php @@ -3,12 +3,13 @@ namespace Appwrite\GraphQL\Types; use GraphQL\Language\AST\Node; +use GraphQL\Language\AST\StringValueNode; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Assoc extends Json { - public $name = 'Assoc'; - public $description = 'The `Assoc` scalar type represents associative array values.'; + public string $name = 'Assoc'; + public ?string $description = 'The `Assoc` scalar type represents associative array values.'; public function serialize($value) { @@ -30,6 +31,10 @@ class Assoc extends Json public function parseLiteral(Node $valueNode, ?array $variables = null) { - return \json_decode($valueNode->value, true); + if ($valueNode instanceof StringValueNode) { + return \json_decode($valueNode->value, true); + } + + return parent::parseLiteral($valueNode, $variables); } } diff --git a/src/Appwrite/GraphQL/Types/InputFile.php b/src/Appwrite/GraphQL/Types/InputFile.php index 39fd4e23b3..daa771911b 100644 --- a/src/Appwrite/GraphQL/Types/InputFile.php +++ b/src/Appwrite/GraphQL/Types/InputFile.php @@ -8,8 +8,8 @@ use GraphQL\Type\Definition\ScalarType; class InputFile extends ScalarType { - public $name = 'InputFile'; - public $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by + public string $name = 'InputFile'; + public ?string $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).'; public function serialize($value) diff --git a/src/Appwrite/GraphQL/Types/Json.php b/src/Appwrite/GraphQL/Types/Json.php index 18d27322a1..627b9081d3 100644 --- a/src/Appwrite/GraphQL/Types/Json.php +++ b/src/Appwrite/GraphQL/Types/Json.php @@ -14,8 +14,8 @@ use GraphQL\Type\Definition\ScalarType; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Json extends ScalarType { - public $name = 'Json'; - public $description = 'The `JSON` scalar type represents JSON values as specified by + public string $name = 'Json'; + public ?string $description = 'The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).'; public function serialize($value) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index e60810417c..e192dff2a0 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -21,6 +21,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Multiple; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -68,6 +69,7 @@ class Create extends Action ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -88,6 +90,7 @@ class Create extends Action bool $tls, string $authUsername, string $authPassword, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -107,7 +110,7 @@ class Create extends Action 'security' => $tls, 'httpUser' => $authUsername, 'httpPass' => $authPassword, - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index ebe6fa7bcb..a42500ca46 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -72,6 +72,8 @@ class Get extends Action throw new Exception(Exception::WEBHOOK_NOT_FOUND); } + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php similarity index 86% rename from src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php rename to src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php index e36fa5c2ce..fbff94735e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php @@ -1,6 +1,6 @@ param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -62,6 +65,7 @@ class Update extends Action public function action( string $webhookId, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -78,7 +82,7 @@ class Update extends Action } $updates = new Document([ - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), ]); $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index 6085eb74b2..e7b516449e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -118,6 +118,8 @@ class Update extends Action $queueForEvents->setParam('webhookId', $webhook->getId()); + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 763c0d339b..f0961b541c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -120,6 +120,10 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } + foreach ($webhooks as $webhook) { + $webhook->removeAttribute('signatureKey'); + } + $response->dynamic(new Document([ 'webhooks' => $webhooks, 'total' => $total, diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php index 4805de6ebc..0e9c39a762 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook; -use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret\Update as UpdateWebhookSecret; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks; use Utopia\Platform\Service; @@ -26,6 +26,6 @@ class Http extends Service $this->addAction(GetWebhook::getName(), new GetWebhook()); $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); - $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); + $this->addAction(UpdateWebhookSecret::getName(), new UpdateWebhookSecret()); } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 88725a190a..f867884801 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -7,6 +7,8 @@ use Cron\CronExpression; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Span\Span; +use Utopia\System\System; /** * ScheduleFunctions @@ -88,7 +90,7 @@ class ScheduleFunctions extends ScheduleBase $scheduleKey = $delayConfig['key']; // Ensure schedule was not deleted if (!\array_key_exists($scheduleKey, $this->schedules)) { - return; + continue; } $schedule = $this->schedules[$scheduleKey]; @@ -102,8 +104,22 @@ class ScheduleFunctions extends ScheduleBase ->setFunction($schedule['resource']) ->setMethod('POST') ->setPath('/') - ->setProject($schedule['project']) - ->trigger(); + ->setProject($schedule['project']); + + $projectDoc = $schedule['project']; + $functionDoc = $schedule['resource']; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $projectDoc->getId() === $traceProjectId && $functionDoc->getId() === $traceFunctionId) { + Span::init('execution.trace.v1_functions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $projectDoc->getId()); + Span::add('functionId', $functionDoc->getId()); + Span::add('scheduleId', $schedule['$id'] ?? ''); + Span::current()?->finish(); + } + + $queueForFunctions->trigger(); $this->recordEnqueueDelay($delayConfig['nextDate']); } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index c420444112..f4978780a1 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -759,11 +759,19 @@ class Deletes extends Action $databasesToClean )); } elseif ($sharedTablesV1) { + /** + * Temporary disabling deletes for internal collections + */ + $queries = \array_map( + fn ($id) => Query::notEqual('$id', $id), + $projectCollectionIds + ); + + $queries[] = Query::orderAsc(); + $this->deleteByGroup( Database::METADATA, - [ - Query::orderAsc() - ], + $queries, $dbForProject ); } elseif ($sharedTablesV2) { diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 673e9de791..99e20be035 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -7,6 +7,8 @@ use Exception; use Utopia\Database\Database; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; +use Utopia\System\System; class Executions extends Action { @@ -39,6 +41,20 @@ class Executions extends Action throw new Exception('Missing execution'); } + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $executionMessage->project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.executions_worker_upsert'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $executionMessage->project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('resourceType', $execution->getAttribute('resourceType', '')); + Span::current()?->finish(); + } + $dbForProject->upsertDocument('executions', $execution); } } diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index bed28dad1c..0899fbacb4 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -23,6 +23,7 @@ use Utopia\Database\Query; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; use Utopia\System\System; class Functions extends Action @@ -115,6 +116,22 @@ class Functions extends Action $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); + if (empty($events) && !$function->isEmpty()) { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_dequeue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('payloadType', $type); + Span::add('queuePid', $message->getPid()); + Span::add('queueName', $message->getQueue()); + Span::add('messageTimestamp', (string) $message->getTimestamp()); + Span::current()?->finish(); + } + } + if (!empty($events)) { $limit = 100; $sum = 100; @@ -304,6 +321,20 @@ class Functions extends Action 'duration' => 0.0, ]); + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus_fail'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('trigger', $trigger); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), @@ -522,6 +553,18 @@ class Functions extends Action $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_executor'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $executionId); + Span::add('deploymentId', $deployment->getId()); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $executionResponse = $executor->createExecution( projectId: $project->getId(), deploymentId: $deploymentId, @@ -594,6 +637,19 @@ class Functions extends Action $errorCode = $th->getCode(); } finally { /** Persist final execution status and record usage */ + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index c2a2c183b1..6a0197e4a1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -71,7 +71,7 @@ class Webhook extends Model ]) ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validate incoming webhook payloads.', + 'description' => 'Signature key which can be used to validate incoming webhook payloads. Only returned on creation and secret rotation.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index e6d6996748..148b29c1d1 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -22,7 +22,7 @@ class Comment 'Every Git commit and branch gets its own deployment URL automatically', 'Custom domains work with both CNAME for subdomains and NS records for apex domains', 'HTTPS and SSL certificates are handled automatically for all your Sites', - 'Functions can run for up to 15 minutes before timing out', + 'Function builds can take up to 45 minutes before timing out', 'Schedule functions to run as often as every minute with cron expressions', 'Environment variables can be scoped per function or shared across your project', 'Function scopes give you fine-grained control over API permissions', diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 82181aa858..f926a97c81 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -694,10 +694,10 @@ trait WebhooksBase $this->assertEquals(128, \strlen($updated['body']['secret'])); $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new secret persisted via GET + // Verify secret is not exposed via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSecret, $get['body']['secret']); + $this->assertEmpty($get['body']['secret']); // Test secret update on non-existent webhook $notFound = $this->updateWebhookSecret('non-existent-id'); @@ -708,6 +708,351 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } + public function testSecretRotationZeroDowntime(): void + { + // Create webhook pointing to request-catcher so deliveries are captured + $webhook = $this->createWebhook( + ID::unique(), + 'Rotation Test Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $originalSecret = $webhook['body']['secret']; + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); + + // Step 1: Trigger user creation with the original auto-generated secret + $email1 = uniqid() . 'rotation1@localhost.test'; + $user1 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email1, + 'password' => 'password', + 'name' => 'Rotation User 1', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + $userId1 = $user1['body']['$id']; + + // Verify webhook delivery is signed with the original secret + $this->assertEventually(function () use ($userId1, $originalSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId1) { + $this->assertStringContainsString( + "users.{$userId1}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $originalSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Step 2: Rotate the secret to a known custom value + $newSecret = 'new-key-after-rotation'; + $updated = $this->updateWebhookSecret($webhookId, $newSecret); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($newSecret, $updated['body']['secret']); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); + + // Step 3: Trigger another user creation — should be signed with the new secret + $email2 = uniqid() . 'rotation2@localhost.test'; + $user2 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email2, + 'password' => 'password', + 'name' => 'Rotation User 2', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + $userId2 = $user2['body']['$id']; + + // Verify webhook delivery is signed with the new rotated secret + $this->assertEventually(function () use ($userId2, $newSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId2) { + $this->assertStringContainsString( + "users.{$userId2}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $newSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookWithCustomSecret(): void + { + $customSecret = 'custom-secret-key'; + + // Create webhook with a custom secret pointing to request-catcher + $webhook = $this->createWebhook( + ID::unique(), + 'Custom Secret Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null, + $customSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals($customSecret, $webhook['body']['secret']); + + // Trigger user creation to generate a webhook delivery + $email = uniqid() . 'customsecret@localhost.test'; + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Custom Secret User', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Verify webhook delivery is signed with the custom secret + $this->assertEventually(function () use ($userId, $customSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId) { + $this->assertStringContainsString( + "users.{$userId}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $customSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookSecretMinLength(): void + { + // 7 chars — below minimum of 8 + $webhook = $this->createWebhook( + ID::unique(), + 'Short Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'short12' + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + + // 8 chars — exactly at minimum + $webhook = $this->createWebhook( + ID::unique(), + 'Min Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'exact8ch' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals('exact8ch', $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testCreateWebhookSecretMaxLength(): void + { + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $webhook = $this->createWebhook( + ID::unique(), + 'Max Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $maxSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals($maxSecret, $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $webhook = $this->createWebhook( + ID::unique(), + 'Too Long Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $tooLongSecret + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookSecretMinLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Min Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 7 chars — below minimum of 8 + $updated = $this->updateWebhookSecret($webhookId, 'short12'); + $this->assertEquals(400, $updated['headers']['status-code']); + + // 8 chars — exactly at minimum + $updated = $this->updateWebhookSecret($webhookId, 'exact8ch'); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('exact8ch', $updated['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretMaxLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Max Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $updated = $this->updateWebhookSecret($webhookId, $maxSecret); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($maxSecret, $updated['body']['secret']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $updated = $this->updateWebhookSecret($webhookId, $tooLongSecret); + $this->assertEquals(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testWebhookSecretNotExposedInResponses(): void + { + // Create webhook — secret IS returned on creation + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Exposure Test', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'my-custom-secret' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals('my-custom-secret', $webhook['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $webhook['body']); + + // Get webhook — secret must not be exposed + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEmpty($get['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $get['body']); + + // List webhooks — secret must not be exposed + $list = $this->listWebhooks(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + foreach ($list['body']['webhooks'] as $item) { + $this->assertEmpty($item['secret']); + $this->assertArrayNotHasKey('signatureKey', $item); + } + + // Update webhook — secret must not be exposed + $updated = $this->updateWebhook( + $webhookId, + 'Secret Exposure Test Updated', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEmpty($updated['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $updated['body']); + + // Update webhook secret — secret IS returned on rotation + $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); + $this->assertEquals(200, $rotated['headers']['status-code']); + $this->assertEquals('rotated-secret-key', $rotated['body']['secret']); + $this->assertArrayNotHasKey('signatureKey', $rotated['body']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // URL validation tests public function testCreateWebhookWithPrivateDomain(): void @@ -883,6 +1228,12 @@ trait WebhooksBase { $customId = 'my-custom-webhook-id'; + // Clean up stale webhook from a previous run if it exists + $existing = $this->getWebhook($customId); + if ($existing['headers']['status-code'] === 200) { + $this->deleteWebhook($customId); + } + $webhook = $this->createWebhook( $customId, 'Custom ID Webhook', @@ -902,6 +1253,19 @@ trait WebhooksBase $this->assertEquals(200, $get['headers']['status-code']); $this->assertEquals($customId, $get['body']['$id']); + // Ensure duplicate creation fails + $duplicate = $this->createWebhook( + $customId, + 'Duplicate Custom ID Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(409, $duplicate['headers']['status-code']); + // Cleanup $this->deleteWebhook($customId); } @@ -937,8 +1301,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['tls']); $this->assertEquals('myuser', $get['body']['authUsername']); $this->assertEquals('mypass', $get['body']['authPassword']); - $this->assertNotEmpty($get['body']['secret']); - $this->assertEquals(128, \strlen($get['body']['secret'])); + $this->assertEmpty($get['body']['secret']); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1645,7 +2008,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['security']); $this->assertEquals('getuser', $get['body']['httpUser']); $this->assertEquals('getpass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); + $this->assertEmpty($get['body']['signatureKey']); // Cleanup $this->deleteWebhook($webhookId); @@ -1779,7 +2142,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword, ?string $secret = null): mixed { $params = [ 'webhookId' => $webhookId, @@ -1800,6 +2163,9 @@ trait WebhooksBase if ($authPassword !== null) { $params['authPassword'] = $authPassword; } + if ($secret !== null) { + $params['secret'] = $secret; + } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', @@ -1838,12 +2204,17 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSecret(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId, ?string $secret = null): mixed { + $params = []; + if ($secret !== null) { + $params['secret'] = $secret; + } + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ], $this->getHeaders()), $params); return $webhook; } diff --git a/tests/unit/GraphQL/BuilderTest.php b/tests/unit/GraphQL/BuilderTest.php index 3dd1bcadc7..9190ce3e78 100644 --- a/tests/unit/GraphQL/BuilderTest.php +++ b/tests/unit/GraphQL/BuilderTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\GraphQL; use Appwrite\GraphQL\Types\Mapper; use Appwrite\Utopia\Response; +use GraphQL\Type\Definition\NamedType; use PHPUnit\Framework\TestCase; use Swoole\Http\Response as SwooleResponse; @@ -24,6 +25,7 @@ class BuilderTest extends TestCase { $model = $this->response->getModel(Response::MODEL_TABLE); $type = Mapper::model(\ucfirst($model->getType())); - $this->assertEquals('Table', $type->name); + $this->assertInstanceOf(NamedType::class, $type); + $this->assertEquals('Table', $type->name()); } }