diff --git a/Dockerfile b/Dockerfile index e848b6f0b5..6faaf9ed2b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -81,6 +81,7 @@ RUN chmod +x /usr/local/bin/doctor && \ chmod +x /usr/local/bin/worker-certificates && \ chmod +x /usr/local/bin/worker-databases && \ chmod +x /usr/local/bin/worker-deletes && \ + chmod +x /usr/local/bin/worker-executions && \ chmod +x /usr/local/bin/worker-functions && \ chmod +x /usr/local/bin/worker-mails && \ chmod +x /usr/local/bin/worker-messaging && \ diff --git a/app/config/errors.php b/app/config/errors.php index 62affd8101..16fad57de1 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1139,6 +1139,11 @@ return [ 'description' => 'Key with the requested ID could not be found.', 'code' => 404, ], + Exception::KEY_ALREADY_EXISTS => [ + 'name' => Exception::KEY_ALREADY_EXISTS, + 'description' => 'Key with the same ID already exists. Try again with a different ID.', + 'code' => 409, + ], Exception::PLATFORM_NOT_FOUND => [ 'name' => Exception::PLATFORM_NOT_FOUND, 'description' => 'Platform with the requested ID could not be found.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 2d9de7fe77..2ab880d307 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1469,13 +1469,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, 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(); @@ -1512,6 +1513,29 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $state = $defaultState; } + // Allow redirect to rule URL if related to project + // Check if $redirectValidator is instance of Redirect class + if ($redirectValidator instanceof Redirect) { + $domains = \array_filter([ + parse_url($state['success'], PHP_URL_HOST) ?? '', + parse_url($state['failure'], PHP_URL_HOST) ?? '' + ], fn ($domain) => \is_string($domain) && $domain !== ''); + + if (!empty($domains)) { + $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', \array_values(\array_unique($domains))), + Query::equal('projectInternalId', [$project->getSequence()]), + Query::limit(2) + ])); + + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule->getAttribute('domain', ''); + $redirectValidator->setAllowedHostnames($allowedHostnames); + } + } + } + if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { throw new Exception(Exception::PROJECT_INVALID_SUCCESS_URL); } diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index b09e9e50c3..51d141dc27 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -14,16 +14,21 @@ 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\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Duplicate; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Domains\Validator\PublicDomain; use Utopia\Http\Http; @@ -1094,12 +1099,15 @@ Http::post('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + // 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 $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) { + $keyId = $keyId == 'unique()' ? ID::unique() : $keyId; $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1108,7 +1116,7 @@ Http::post('/v1/projects/:projectId/keys') } $key = new Document([ - '$id' => ID::unique(), + '$id' => $keyId, '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -1125,7 +1133,11 @@ Http::post('/v1/projects/:projectId/keys') 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), ]); - $key = $dbForPlatform->createDocument('keys', $key); + try { + $key = $dbForPlatform->createDocument('keys', $key); + } catch (Duplicate) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } $dbForPlatform->purgeCachedDocument('projects', $project->getId()); @@ -1152,10 +1164,11 @@ Http::get('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1163,15 +1176,46 @@ Http::get('/v1/projects/:projectId/keys') throw new Exception(Exception::PROJECT_NOT_FOUND); } - $keys = $dbForPlatform->find('keys', [ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - Query::limit(5000), - ]); + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Backwards compatibility + if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { + $queries[] = Query::limit(5000); + } + + $queries[] = Query::equal('resourceType', ['projects']); + $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $keyId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('keys', $keyId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $keys = $dbForPlatform->find('keys', $queries); $response->dynamic(new Document([ 'keys' => $keys, - 'total' => $includeTotal ? count($keys) : 0, + 'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0, ]), Response::MODEL_KEY_LIST); }); diff --git a/app/controllers/general.php b/app/controllers/general.php index cdf8586537..e1a259bbc4 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -8,7 +8,7 @@ use Appwrite\Auth\Key; use Appwrite\Event\Certificate; use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; -use Appwrite\Event\Func; +use Appwrite\Event\Execution; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Network\Cors; @@ -60,7 +60,7 @@ Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); -function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) +function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $host = $request->getHostname() ?? ''; if (!empty($previewHostname)) { @@ -696,14 +696,11 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $th; } } finally { - if ($type === 'function') { - $queueForFunctions - ->setType(Func::TYPE_ASYNC_WRITE) + if ($type === 'function' || $type === 'site') { + $queueForExecutions ->setExecution($execution) ->setProject($project) ->trigger(); - } elseif ($type === 'site') { // TODO: Move it to logs worker later - $dbForProject->createDocument('executions', $execution); } } @@ -877,7 +874,7 @@ Http::init() ->inject('geodb') ->inject('queueForStatsUsage') ->inject('queueForEvents') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('platform') ->inject('isResourceBlocked') @@ -888,7 +885,7 @@ Http::init() ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Func $queueForFunctions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Execution $queueForExecutions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ @@ -896,7 +893,7 @@ Http::init() $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1175,7 +1172,7 @@ Http::options() ->inject('getProjectDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1188,14 +1185,14 @@ Http::options() ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1571,7 +1568,7 @@ Http::get('/robots.txt') ->inject('getProjectDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1581,13 +1578,13 @@ Http::get('/robots.txt') ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/robots.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1606,7 +1603,7 @@ Http::get('/humans.txt') ->inject('getProjectDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1616,13 +1613,13 @@ Http::get('/humans.txt') ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/humans.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } diff --git a/app/init/constants.php b/app/init/constants.php index 0ee5271d7f..f084359068 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -5,6 +5,8 @@ use Appwrite\Platform\Modules\Compute\Specification; const APP_NAME = 'Appwrite'; const APP_DOMAIN = 'appwrite.io'; +const APP_VIEWS_DIR = __DIR__ . '/../views'; + // Email const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address const APP_EMAIL_SECURITY = ''; // Default security email address diff --git a/app/init/resources.php b/app/init/resources.php index bbefc4a9f3..96b316de2b 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -10,6 +10,7 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; +use Appwrite\Event\Execution; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; @@ -159,6 +160,9 @@ Http::setResource('queueForAudits', function (Publisher $publisher) { Http::setResource('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); +Http::setResource('queueForExecutions', function (Publisher $publisher) { + return new Execution($publisher); +}, ['publisher']); Http::setResource('eventProcessor', function () { return new EventProcessor(); }, []); diff --git a/app/worker.php b/app/worker.php index a9c7a94016..e114a72bb3 100644 --- a/app/worker.php +++ b/app/worker.php @@ -9,6 +9,7 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; +use Appwrite\Event\Execution; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; @@ -358,6 +359,10 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); +Server::setResource('queueForExecutions', function (Publisher $publisher) { + return new Execution($publisher); +}, ['publisher']); + Server::setResource('queueForRealtime', function () { return new Realtime(); }, []); diff --git a/bin/worker-executions b/bin/worker-executions new file mode 100755 index 0000000000..6789fb8da8 --- /dev/null +++ b/bin/worker-executions @@ -0,0 +1,3 @@ +#!/bin/sh + +exec php /usr/src/code/app/worker.php executions "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 37305055e5..a1f5ca77c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -641,6 +641,37 @@ services: - _APP_LOGGING_CONFIG - _APP_DATABASE_SHARED_TABLES + appwrite-worker-executions: + entrypoint: worker-executions + <<: *x-logging + container_name: appwrite-worker-executions + image: appwrite-dev + networks: + - appwrite + volumes: + - ./app:/usr/src/code/app + - ./src:/usr/src/code/src + depends_on: + redis: + condition: service_started + mariadb: + condition: service_started + environment: + - _APP_ENV + - _APP_WORKER_PER_CORE + - _APP_POOL_ADAPTER + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_LOGGING_CONFIG + - _APP_DATABASE_SHARED_TABLES + appwrite-worker-functions: entrypoint: worker-functions <<: *x-logging diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index 6db001398f..6f00d0cd0e 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -46,6 +46,9 @@ class Event public const MESSAGING_QUEUE_NAME = 'v1-messaging'; public const MESSAGING_CLASS_NAME = 'MessagingV1'; + public const EXECUTIONS_QUEUE_NAME = 'v1-executions'; + public const EXECUTIONS_CLASS_NAME = 'ExecutionsV1'; + public const MIGRATIONS_QUEUE_NAME = 'v1-migrations'; public const MIGRATIONS_CLASS_NAME = 'MigrationsV1'; diff --git a/src/Appwrite/Event/Execution.php b/src/Appwrite/Event/Execution.php new file mode 100644 index 0000000000..398025565c --- /dev/null +++ b/src/Appwrite/Event/Execution.php @@ -0,0 +1,56 @@ +setQueue(Event::EXECUTIONS_QUEUE_NAME) + ->setClass(Event::EXECUTIONS_CLASS_NAME); + } + + /** + * Sets execution document for the execution event. + * + * @param Document $execution + * @return self + */ + public function setExecution(Document $execution): self + { + $this->execution = $execution; + + return $this; + } + + /** + * Returns set execution document for the execution event. + * + * @return null|Document + */ + public function getExecution(): ?Document + { + return $this->execution; + } + + /** + * Prepare payload for the execution event. + * + * @return array + */ + protected function preparePayload(): array + { + return [ + 'project' => $this->project, + 'execution' => $this->execution, + ]; + } +} diff --git a/src/Appwrite/Event/Func.php b/src/Appwrite/Event/Func.php index 2f7f8e3c5c..7790437e1c 100644 --- a/src/Appwrite/Event/Func.php +++ b/src/Appwrite/Event/Func.php @@ -9,8 +9,6 @@ use Utopia\System\System; class Func extends Event { - public const TYPE_ASYNC_WRITE = 'async_write'; - protected string $jwt = ''; protected string $type = ''; protected string $body = ''; diff --git a/src/Appwrite/Event/StatsUsage.php b/src/Appwrite/Event/StatsUsage.php index 47ba5a3ea0..a944d70c94 100644 --- a/src/Appwrite/Event/StatsUsage.php +++ b/src/Appwrite/Event/StatsUsage.php @@ -86,4 +86,11 @@ class StatsUsage extends Event }), ]; } + + public function reset(): Event + { + $this->metrics = []; + parent::reset(); + return $this; + } } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 2bc7021b31..d02f3902a3 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -318,6 +318,7 @@ class Exception extends \Exception /** Keys */ public const string KEY_NOT_FOUND = 'key_not_found'; + public const string KEY_ALREADY_EXISTS = 'key_already_exists'; /** Variables */ public const string VARIABLE_NOT_FOUND = 'variable_not_found'; 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. diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php index 5db6cb6e43..6188c14b5d 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php @@ -31,7 +31,7 @@ class Get extends Action ->desc('Create GitHub app installation') ->groups(['api', 'vcs']) ->label('scope', 'vcs.read') - ->label('error', __DIR__ . '/../../views/general/error.phtml') + ->label('error', APP_VIEWS_DIR . '/general/error.phtml') ->label('sdk', new Method( namespace: 'vcs', group: 'installations', diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php index 535f26e0cd..1212c06a72 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -35,7 +35,7 @@ class Get extends Action ->desc('Get installation and authorization from GitHub app') ->groups(['api', 'vcs']) ->label('scope', 'public') - ->label('error', __DIR__ . '/../../views/general/error.phtml') + ->label('error', APP_VIEWS_DIR . '/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) diff --git a/src/Appwrite/Platform/Services/Workers.php b/src/Appwrite/Platform/Services/Workers.php index f2cbeb390a..fa77725f7a 100644 --- a/src/Appwrite/Platform/Services/Workers.php +++ b/src/Appwrite/Platform/Services/Workers.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Services; use Appwrite\Platform\Workers\Audits; use Appwrite\Platform\Workers\Certificates; use Appwrite\Platform\Workers\Deletes; +use Appwrite\Platform\Workers\Executions; use Appwrite\Platform\Workers\Functions; use Appwrite\Platform\Workers\Mails; use Appwrite\Platform\Workers\Messaging; @@ -23,6 +24,7 @@ class Workers extends Service ->addAction(Audits::getName(), new Audits()) ->addAction(Certificates::getName(), new Certificates()) ->addAction(Deletes::getName(), new Deletes()) + ->addAction(Executions::getName(), new Executions()) ->addAction(Functions::getName(), new Functions()) ->addAction(Mails::getName(), new Mails()) ->addAction(Messaging::getName(), new Messaging()) diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php new file mode 100644 index 0000000000..300a84162c --- /dev/null +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -0,0 +1,52 @@ +desc('Executions worker') + ->groups(['executions']) + ->inject('message') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + Message $message, + Database $dbForProject, + ): void { + $payload = $message->getPayload() ?? []; + + if (empty($payload)) { + throw new Exception('Missing payload'); + } + + $execution = new Document($payload['execution'] ?? []); + + if ($execution->isEmpty()) { + throw new Exception('Missing execution'); + } + + if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check + $dbForProject->upsertDocument('executions', $execution); + } + } +} diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 90f2e480c8..9f1f328fd6 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Event; +use Appwrite\Event\Execution as ExecutionEvent; use Appwrite\Event\Func; use Appwrite\Event\Realtime; use Appwrite\Event\StatsUsage; @@ -16,8 +17,6 @@ use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Conflict; -use Utopia\Database\Exception\Structure; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -50,6 +49,7 @@ class Functions extends Action ->inject('queueForRealtime') ->inject('queueForEvents') ->inject('queueForStatsUsage') + ->inject('queueForExecutions') ->inject('log') ->inject('executor') ->inject('isResourceBlocked') @@ -65,6 +65,7 @@ class Functions extends Action Realtime $queueForRealtime, Event $queueForEvents, StatsUsage $queueForStatsUsage, + ExecutionEvent $queueForExecutions, Log $log, Executor $executor, callable $isResourceBlocked @@ -77,15 +78,6 @@ class Functions extends Action $type = $payload['type'] ?? ''; - // Short-term solution to offhand write operation from API container - if ($type === Func::TYPE_ASYNC_WRITE) { - $execution = new Document($payload['execution'] ?? []); - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - $dbForProject->createDocument('executions', $execution); - } - return; - } - $events = $payload['events'] ?? []; $data = $payload['body'] ?? ''; $eventData = $payload['payload'] ?? ''; @@ -166,6 +158,7 @@ class Functions extends Action queueForRealtime: $queueForRealtime, queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, + queueForExecutions: $queueForExecutions, project: $project, function: $function, executor: $executor, @@ -210,6 +203,7 @@ class Functions extends Action queueForRealtime: $queueForRealtime, queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, + queueForExecutions: $queueForExecutions, project: $project, function: $function, executor: $executor, @@ -236,6 +230,7 @@ class Functions extends Action queueForRealtime: $queueForRealtime, queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, + queueForExecutions: $queueForExecutions, project: $project, function: $function, executor: $executor, @@ -268,7 +263,8 @@ class Functions extends Action */ private function fail( string $message, - Database $dbForProject, + Document $project, + ExecutionEvent $queueForExecutions, Document $function, string $trigger, string $path, @@ -311,13 +307,10 @@ class Functions extends Action 'duration' => 0.0, ]); - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - $execution = $dbForProject->createDocument('executions', $execution); - - if ($execution->isEmpty()) { - throw new Exception('Failed to create execution'); - } - } + $queueForExecutions + ->setExecution($execution) + ->setProject($project) + ->trigger(); } /** @@ -341,9 +334,6 @@ class Functions extends Action * @param string|null $eventData * @param string|null $executionId * @return void - * @throws Structure - * @throws \Utopia\Database\Exception - * @throws Conflict */ private function execute( Log $log, @@ -353,6 +343,7 @@ class Functions extends Action Realtime $queueForRealtime, StatsUsage $queueForStatsUsage, Event $queueForEvents, + ExecutionEvent $queueForExecutions, Document $project, Document $function, Executor $executor, @@ -380,19 +371,19 @@ class Functions extends Action if ($deployment->getAttribute('resourceId') !== $functionId) { $errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.'; - $this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); return; } if ($deployment->isEmpty()) { $errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.'; - $this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); return; } if ($deployment->getAttribute('status') !== 'ready') { $errorMessage = 'The execution could not be completed because the build is not ready. Please wait for the build to complete and try again.'; - $this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); return; } @@ -423,60 +414,38 @@ class Functions extends Action $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; - /** Create execution or update execution status */ - $execution = $dbForProject->getDocument('executions', $executionId ?? ''); - if ($execution->isEmpty()) { + /** Create or update execution to processing status */ + if (empty($executionId)) { $executionId = ID::unique(); - $headers['x-appwrite-execution-id'] = $executionId; - $headersFiltered = []; - foreach ($headers as $key => $value) { - if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { - $headersFiltered[] = [ 'name' => $key, 'value' => $value ]; - } - } + } + $headers['x-appwrite-execution-id'] = $executionId; - $execution = new Document([ - '$id' => $executionId, - '$permissions' => $user->isEmpty() ? [] : [Permission::read(Role::user($user->getId()))], - 'resourceInternalId' => $function->getSequence(), - 'resourceId' => $function->getId(), - 'resourceType' => 'functions', - 'deploymentInternalId' => $deployment->getSequence(), - 'deploymentId' => $deployment->getId(), - 'trigger' => $trigger, - 'status' => 'processing', - 'responseStatusCode' => 0, - 'responseHeaders' => [], - 'requestPath' => $path, - 'requestMethod' => $method, - 'requestHeaders' => $headersFiltered, - 'errors' => '', - 'logs' => '', - 'duration' => 0.0, - ]); - - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - $execution = $dbForProject->createDocument('executions', $execution); - - // TODO: @Meldiron Trigger executions.create event here - - if ($execution->isEmpty()) { - throw new Exception('Failed to create or read execution'); - } + $headersFiltered = []; + foreach ($headers as $key => $value) { + if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { + $headersFiltered[] = [ 'name' => $key, 'value' => $value ]; } } - if ($execution->getAttribute('status') !== 'processing') { - $execution->setAttribute('status', 'processing'); - - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - try { - $execution = $dbForProject->updateDocument('executions', $executionId, $execution); - } catch (\Throwable $e) { - $log->addExtra('updateError', $e->getMessage()); - } - } - } + $execution = new Document([ + '$id' => $executionId, + '$permissions' => $user->isEmpty() ? [] : [Permission::read(Role::user($user->getId()))], + 'resourceInternalId' => $function->getSequence(), + 'resourceId' => $function->getId(), + 'resourceType' => 'functions', + 'deploymentInternalId' => $deployment->getSequence(), + 'deploymentId' => $deployment->getId(), + 'trigger' => $trigger, + 'status' => 'processing', + 'responseStatusCode' => 0, + 'responseHeaders' => [], + 'requestPath' => $path, + 'requestMethod' => $method, + 'requestHeaders' => $headersFiltered, + 'errors' => '', + 'logs' => '', + 'duration' => 0.0, + ]); $durationStart = \microtime(true); @@ -618,14 +587,11 @@ class Functions extends Action $error = $th->getMessage(); $errorCode = $th->getCode(); } finally { - /** Update execution status */ - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - try { - $execution = $dbForProject->updateDocument('executions', $executionId, $execution); - } catch (\Throwable $e) { - $log->addExtra('updateError', $e->getMessage()); - } - } + /** Persist final execution status */ + $queueForExecutions + ->setExecution($execution) + ->setProject($project) + ->trigger(); /** Trigger usage queue */ $queueForStatsUsage diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 559fc14111..c6b005c334 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Mail; use Appwrite\Event\Realtime; +use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Template\Template; use Utopia\Config\Config; @@ -25,6 +26,10 @@ use Utopia\Migration\Destination; use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; use Utopia\Migration\Destinations\CSV as DestinationCSV; use Utopia\Migration\Exception as MigrationException; +use Utopia\Migration\Resource; +use Utopia\Migration\Resources\Database\Database as ResourceDatabase; +use Utopia\Migration\Resources\Database\Row as ResourceRow; +use Utopia\Migration\Resources\Database\Table as ResourceTable; use Utopia\Migration\Source; use Utopia\Migration\Sources\Appwrite as SourceAppwrite; use Utopia\Migration\Sources\CSV; @@ -52,6 +57,7 @@ class Migrations extends Action */ protected array $sourceReport = []; + private string $source; /** * @var callable|null */ @@ -78,6 +84,7 @@ class Migrations extends Action ->inject('deviceForMigrations') ->inject('deviceForFiles') ->inject('queueForMails') + ->inject('queueForStatsUsage') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); @@ -96,6 +103,7 @@ class Migrations extends Action Device $deviceForMigrations, Device $deviceForFiles, Mail $queueForMails, + StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization, ): void { @@ -139,6 +147,7 @@ class Migrations extends Action $migration, $queueForRealtime, $queueForMails, + $queueForStatsUsage, $platform, $authorization ); @@ -324,6 +333,7 @@ class Migrations extends Action Document $migration, Realtime $queueForRealtime, Mail $queueForMails, + StatsUsage $queueForStatsUsage, array $platform, Authorization $authorization, ): void { @@ -368,6 +378,7 @@ class Migrations extends Action $destination ); + $aggregatedResources = []; /** Start Transfer */ if (empty($source->getErrors())) { $migration->setAttribute('stage', 'migrating'); @@ -375,9 +386,40 @@ class Migrations extends Action $transfer->run( $migration->getAttribute('resources'), - function () use ($migration, $transfer, $project, $queueForRealtime) { + function ($resources) use ($migration, $transfer, $project, $queueForRealtime, &$aggregatedResources) { $migration->setAttribute('resourceData', json_encode($transfer->getCache())); $migration->setAttribute('statusCounters', json_encode($transfer->getStatusCounters())); + + if (!empty($resources)) { + /** + * @var Resource $resource + */ + $resource = $resources[0]; + $count = count($resources); + $databaseId = null; + $tableId = null; + switch ($resource->getName()) { + case ResourceTable::getName(): + /** @var ResourceTable $resource */ + $databaseId = $resource->getDatabase()->getSequence(); + break; + case ResourceRow::getName(): + /** @var ResourceRow $resource */ + $table = $resource->getTable(); + $databaseId = $table->getDatabase()->getSequence(); + $tableId = $table->getSequence(); + break; + default: + break; + } + $aggregatedResources[] = [ + 'name' => $resource->getName(), + 'count' => $count, + 'databaseId' => $databaseId, + 'tableId' => $tableId + ]; + + } $this->updateMigrationDocument($migration, $project, $queueForRealtime); }, $migration->getAttribute('resourceId'), @@ -443,6 +485,16 @@ class Migrations extends Action } if ($migration->getAttribute('status', '') === 'completed') { + foreach ($aggregatedResources as $resource) { + $this->processMigrationResourceStats( + $resource, + $queueForStatsUsage, + $project, + $migration->getAttribute('source'), + $authorization, + $migration->getAttribute('resourceId') + ); + } $destination?->success(); $source?->success(); @@ -737,4 +789,58 @@ class Migrations extends Action return $errors; } + + private function processMigrationResourceStats(array $resources, StatsUsage $queueForStatsUsage, Document $projectDocument, string $source, Authorization $authorization, ?string $resourceId) + { + $resourceName = $resources['name']; + $count = $resources['count']; + $databaseInternalId = $resources['databaseId']; + $tableInternalId = $resources['tableId']; + + if ($source === CSV::getName()) { + [$databaseId, $tableId] = explode(':', $resourceId); + $database = $authorization->skip(fn () => $this->dbForProject->getDocument('databases', $databaseId)); + $table = $authorization->skip(fn () => $this->dbForProject->getDocument('database_' . $database->getSequence(), $tableId)); + $databaseInternalId = (int) $database->getSequence(); + $tableInternalId = (int) $table->getSequence(); + } + + switch ($resourceName) { + case ResourceDatabase::getName(): + $queueForStatsUsage->addMetric(METRIC_DATABASES, $count); + break; + + case ResourceTable::getName(): + $queueForStatsUsage + ->addMetric(METRIC_COLLECTIONS, $count) + ->addMetric( + str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), + $count + ); + break; + + case ResourceRow::getName(): + $queueForStatsUsage + ->addMetric( + str_replace( + ['{databaseInternalId}','{collectionInternalId}'], + [$databaseInternalId, $tableInternalId], + METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS + ), + $count + ) + ->addMetric( + str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), + $count + ) + ->addMetric(METRIC_DOCUMENTS, $count); + break; + + default: + break; + } + + $queueForStatsUsage->setProject($projectDocument)->trigger(); + $queueForStatsUsage->reset(); + } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Keys.php b/src/Appwrite/Utopia/Database/Validator/Queries/Keys.php new file mode 100644 index 0000000000..4bae12765c --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Keys.php @@ -0,0 +1,18 @@ + '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/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 5c95e5bdcc..c1a4f7e8a9 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1751,7 +1751,10 @@ class FunctionsCustomServerTest extends Scope $this->assertEventually(function () use ($functionId, $userId) { $executions = $this->listExecutions($functionId); - $lastExecution = $executions['body']['executions'][0]; + $this->assertEquals(200, $executions['headers']['status-code']); + $executionsList = $executions['body']['executions'] ?? []; + $this->assertNotEmpty($executionsList); + $lastExecution = $executionsList[0]; $this->assertEquals('completed', $lastExecution['status']); $this->assertEquals(204, $lastExecution['responseStatusCode']); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e2e5621662..1d15f10971 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1168,7 +1168,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'duration' => 60, // Set session duration to 1 minute + 'duration' => 10, // Set session duration to 10 seconds ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -1177,7 +1177,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertArrayHasKey('platforms', $response['body']); $this->assertArrayHasKey('webhooks', $response['body']); $this->assertArrayHasKey('keys', $response['body']); - $this->assertEquals(60, $response['body']['authDuration']); + $this->assertEquals(10, $response['body']['authDuration']); $projectId = $response['body']['$id']; @@ -1218,44 +1218,30 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); - // Check session doesn't expire too soon. - sleep(30); + // Eventually session expires, within 15 seconds (10+variance) + $this->assertEventually(function () use ($projectId, $sessionCookie) { + // Get User + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'Cookie' => $sessionCookie, + ])); - // Get User - $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'Cookie' => $sessionCookie, - ])); + $this->assertEquals(401, $response['headers']['status-code']); + }, timeoutMs: 15 * 1000); - $this->assertEquals(200, $response['headers']['status-code']); - - // Wait just over a minute - sleep(35); - - // Get User - $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'Cookie' => $sessionCookie, - ])); - - $this->assertEquals(401, $response['headers']['status-code']); - - // Set session duration to 15s + // Set session duration to 10min $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'duration' => 15, // seconds + 'duration' => 600, // seconds ]); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(15, $response['body']['authDuration']); - - // Wait 20 seconds, ensure non-valid session - \sleep(20); + $this->assertEquals(600, $response['body']['authDuration']); + // Ensure session is still expired (new duration only affects new sessions) $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, @@ -2774,6 +2760,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 +3110,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'], ]); @@ -3138,6 +3126,66 @@ class ProjectsConsoleClientTest extends Scope $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 = \uniqid() . '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->assertSame($customKeyId, $response['body']['$id']); + + /** + * Test for FAILURE 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(409, $response['headers']['status-code']); + + /** + * 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->assertNotEmpty($response['body']['$id']); + $this->assertNotSame('unique()', $response['body']['$id']); + $data = array_merge($data, [ 'keyId' => $response['body']['$id'], 'secret' => $response['body']['secret'] @@ -3150,6 +3198,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'], ]); @@ -3167,19 +3216,258 @@ class ProjectsConsoleClientTest extends Scope { $id = $data['projectId'] ?? ''; + /** Create a second key with an expiry for query testing */ + $expireDate = DateTime::addSeconds(new \DateTime(), 3600); + $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 Test 2', + 'scopes' => ['users.read'], + 'expire' => $expireDate, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $key2Id = $response['body']['$id']; + + /** List all keys (no queries) */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(5, $response['body']['total']); + $this->assertCount(5, $response['body']['keys']); + + /** List keys with limit */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals(5, $response['body']['total']); + + /** List keys with offset */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::offset(1)->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(4, $response['body']['keys']); + $this->assertEquals(5, $response['body']['total']); + + /** List keys with cursor after */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => $data['keyId']]))->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals(5, $response['body']['total']); + $this->assertEquals($key2Id, $response['body']['keys'][0]['$id']); + + /** List keys filtering by expire (lessThan now — should match none) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::lessThan('expire', (new \DateTime())->format('Y-m-d H:i:s'))->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + + /** List keys filtering by expire (greaterThan now — should match the key with expiry) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::greaterThan('expire', (new \DateTime())->format('Y-m-d H:i:s'))->toString(), + ] + ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + + /** List keys filtering by name (equal — exact match) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Key Test'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + + /** List keys filtering by name (equal — multiple values) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Key Test', 'Key Test 2'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['total']); + $this->assertCount(2, $response['body']['keys']); + + /** List keys filtering by name (equal — no match) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Non Existent Key'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertCount(0, $response['body']['keys']); + + /** List keys filtering by scopes (contains — match key with teams.read) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('scopes', ['teams.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(4, $response['body']['total']); + $this->assertCount(4, $response['body']['keys']); + + /** List keys filtering by scopes (contains — match key with users.read) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('scopes', ['users.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + + /** List keys filtering by scopes (contains — no match) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('scopes', ['databases.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertCount(0, $response['body']['keys']); + + /** List keys filtering by name and scopes combined */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Key Test'])->toString(), + Query::contains('scopes', ['teams.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + + /** List keys with orderDesc */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::orderDesc('$createdAt')->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(5, $response['body']['keys']); + $this->assertGreaterThan($response['body']['keys'][1]['$createdAt'], $response['body']['keys'][0]['$createdAt']); + + /** List keys with total disabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'total' => false, + 'queries' => [ + Query::limit(1)->toString() + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals(0, $response['body']['total']); /** * Test for FAILURE */ + /** Test invalid query attribute */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('secret', ['test'])->toString(), + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** Test invalid cursor */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => 'invalid']))->toString(), + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + return $data; } @@ -3200,7 +3488,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 +3528,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 +3549,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 +3572,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 +3614,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 +3647,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 +4657,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 +4678,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'], ]); @@ -5191,6 +5486,24 @@ class ProjectsConsoleClientTest extends Scope ], 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, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ]), + 'error' => '', + 'error_description' => '', + ], 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, [ 'content-type' => 'application/json', @@ -5203,6 +5516,24 @@ class ProjectsConsoleClientTest extends Scope ], 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, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain + ]), + 'error' => '', + 'error_deescription' => '', + ], 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', [ 'content-type' => 'application/json', @@ -5230,6 +5561,55 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); } + public function testOAuthRedirectWithCustomSchemeState(): void + { + // Prepare project + $projectId = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testOAuthRedirectWithCustomSchemeState', + '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']); + + $scheme = 'appwrite-callback-' . $projectId; + $state = \json_encode([ + 'success' => $scheme . ':///', + 'failure' => $scheme . ':///' + ]); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'origin' => '', + 'referer' => '', + ], [ + 'code' => 'any-code', + 'state' => $state, + 'error' => 'access_denied', + 'error_description' => 'test', + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith($scheme . '://', $response['headers']['location']); + $this->assertStringContainsString('error=', $response['headers']['location']); + } + /** * @group abuseEnabled */ diff --git a/tests/resources/docker/docker-compose.yml b/tests/resources/docker/docker-compose.yml index c648b2f4c3..8530df0db6 100644 --- a/tests/resources/docker/docker-compose.yml +++ b/tests/resources/docker/docker-compose.yml @@ -212,6 +212,27 @@ services: - _APP_DB_USER - _APP_DB_PASS + appwrite-worker-executions: + entrypoint: worker-executions + container_name: appwrite-worker-executions + build: + context: . + restart: unless-stopped + networks: + - appwrite + depends_on: + - redis + - mariadb + environment: + - _APP_ENV + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + appwrite-worker-functions: entrypoint: worker-functions container_name: appwrite-worker-functions diff --git a/tests/unit/Docker/ComposeTest.php b/tests/unit/Docker/ComposeTest.php index 74779230ac..e63d877baf 100644 --- a/tests/unit/Docker/ComposeTest.php +++ b/tests/unit/Docker/ComposeTest.php @@ -23,7 +23,7 @@ class ComposeTest extends TestCase public function testServices(): void { - $this->assertCount(15, $this->object->getServices()); + $this->assertCount(16, $this->object->getServices()); $this->assertEquals('appwrite', $this->object->getService('appwrite')->getContainerName()); $this->assertEquals('', $this->object->getService('appwrite')->getImageVersion()); $this->assertEquals('3.6', $this->object->getService('traefik')->getImageVersion()); 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://')); + } }