Merge branch '1.9.x' into users-skip-targets

This commit is contained in:
Jake Barnby
2026-05-25 20:09:58 +12:00
committed by GitHub
102 changed files with 1747 additions and 521 deletions
+44
View File
@@ -841,6 +841,28 @@ return [
'array' => true,
'filters' => [],
],
[
'$id' => ID::custom('providerBranches'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
[
'$id' => ID::custom('providerPaths'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
],
'indexes' => [
[
@@ -1320,6 +1342,28 @@ return [
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('providerBranches'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
[
'$id' => ID::custom('providerPaths'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 128,
'signed' => true,
'required' => false,
'default' => [],
'array' => true,
'filters' => [],
],
],
'indexes' => [
[
+1
View File
@@ -22,6 +22,7 @@ return [
'X-Appwrite-Locale',
'X-Appwrite-Mode',
'X-Appwrite-JWT',
'X-Appwrite-Organization',
'X-Appwrite-Response-Format',
'X-Appwrite-Timeout',
'X-Appwrite-ID',
+1 -1
View File
@@ -150,7 +150,7 @@ return [
'label' => 'Owner',
'scopes' => \array_merge($member, $admins),
],
User::ROLE_APPS => [
User::ROLE_KEYS => [
'label' => 'Applications',
'scopes' => ['global', 'health.read', 'graphql'],
],
+1 -1
View File
@@ -39,7 +39,7 @@ Http::init()
if (
array_key_exists('graphql', $project->getAttribute('apis', []))
&& !$project->getAttribute('apis', [])['graphql']
&& !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
&& !($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
+9 -8
View File
@@ -835,7 +835,8 @@ Http::init()
->inject('authorization')
->inject('publisherForDeletes')
->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, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount) {
->inject('params')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeletePublisher $publisherForDeletes, int $executionsRetentionCount, array $params) {
/*
* Appwrite Router
*/
@@ -844,14 +845,14 @@ Http::init()
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
$utopia->match($request)?->route->label('router', true);
}
}
/*
* Request format
*/
$route = $utopia->getRoute();
$route = $utopia->match($request)?->route;
$request->setRoute($route);
if ($route === null) {
@@ -876,7 +877,7 @@ Http::init()
}
if (version_compare($requestFormat, '1.8.0', '<')) {
$dbForProject = $getProjectDB($project);
$request->addFilter(new RequestV20($dbForProject, $route->getPathValues($request)));
$request->addFilter(new RequestV20($dbForProject, $params));
}
if (version_compare($requestFormat, '1.9.0', '<')) {
$request->addFilter(new RequestV21());
@@ -1154,7 +1155,7 @@ Http::options()
// Only run Router when external domain
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
$utopia->match($request)?->route->label('router', true);
}
}
@@ -1189,7 +1190,7 @@ Http::error()
->inject('authorization')
->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) {
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
$route = $utopia->getRoute();
$route = $utopia->match($request)?->route;
$class = \get_class($error);
$code = $error->getCode();
$message = $error->getMessage();
@@ -1555,7 +1556,7 @@ Http::get('/robots.txt')
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
$utopia->match($request)?->route->label('router', true);
}
}
});
@@ -1589,7 +1590,7 @@ Http::get('/humans.txt')
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $publisherForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
$utopia->match($request)?->route->label('router', true);
}
}
});
+3 -4
View File
@@ -13,6 +13,7 @@ use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\UID;
use Utopia\Http\Http;
use Utopia\Http\Route;
use Utopia\Locale\Locale;
use Utopia\System\System;
use Utopia\Validator\Text;
@@ -283,13 +284,11 @@ Http::get('/v1/mock/github/callback')
Http::shutdown()
->groups(['mock'])
->inject('utopia')
->inject('response')
->inject('request')
->action(function (Http $utopia, Response $response, Request $request) {
->inject('route')
->action(function (Response $response, Route $route) {
$result = [];
$route = $utopia->getRoute();
$path = APP_STORAGE_CACHE . '/tests.json';
$tests = (\file_exists($path)) ? \json_decode(\file_get_contents($path), true) : [];
+24 -40
View File
@@ -37,6 +37,7 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Authorization\Input;
use Utopia\Database\Validator\Roles;
use Utopia\Http\Http;
use Utopia\Http\Route;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
@@ -85,7 +86,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
Http::init()
->groups(['api'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('dbForPlatform')
->inject('dbForProject')
@@ -98,11 +99,7 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('authorization')
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
}
->action(function (Route $route, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
/**
* Handle user authentication and session validation.
@@ -178,8 +175,8 @@ Http::init()
$role = $apiKey->getRole();
$scopes = $apiKey->getScopes();
// Handle special app role case
if ($apiKey->getRole() === User::ROLE_APPS) {
// Handle special key role case
if ($apiKey->getRole() === User::ROLE_KEYS) {
// Disable authorization checks for project API keys
// Dynamic supported for backwards compatibility
if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) {
@@ -189,7 +186,7 @@ Http::init()
$user = new User([
'$id' => '',
'status' => true,
'type' => ACTIVITY_TYPE_KEY_PROJECT,
'type' => ACTOR_TYPE_KEY_PROJECT,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $apiKey->getName(),
@@ -261,9 +258,9 @@ Http::init()
$userClone = clone $user;
$userClone->setAttribute('type', match ($apiKey->getType()) {
API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT,
API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT,
default => ACTIVITY_TYPE_KEY_ORGANIZATION,
API_KEY_STANDARD => ACTOR_TYPE_KEY_PROJECT,
API_KEY_ACCOUNT => ACTOR_TYPE_KEY_ACCOUNT,
default => ACTOR_TYPE_KEY_ORGANIZATION,
});
$auditContext->user = $userClone;
}
@@ -428,7 +425,7 @@ Http::init()
if (
array_key_exists($namespace, $project->getAttribute('services', []))
&& ! $project->getAttribute('services', [])[$namespace]
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles()))
) {
throw new Exception(Exception::GENERAL_SERVICE_DISABLED);
}
@@ -438,7 +435,7 @@ Http::init()
if (
array_key_exists('rest', $project->getAttribute('apis', []))
&& ! $project->getAttribute('apis', [])['rest']
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
&& ! ($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
@@ -477,7 +474,7 @@ Http::init()
Http::init()
->groups(['api'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('response')
->inject('project')
@@ -485,21 +482,16 @@ Http::init()
->inject('timelimit')
->inject('devKey')
->inject('authorization')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, callable $timelimit, Document $devKey, Authorization $authorization) {
->action(function (Route $route, Request $request, Response $response, Document $project, User $user, callable $timelimit, Document $devKey, Authorization $authorization) {
$response->setUser($user);
$request->setUser($user);
$roles = $authorization->getRoles();
$shouldCheckAbuse = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'
&& ! $user->isApp($roles)
&& ! $user->isKey($roles)
&& ! $user->isPrivileged($roles)
&& $devKey->isEmpty();
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
}
$abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}');
$abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel;
$closestLimit = null;
@@ -556,7 +548,7 @@ Http::init()
Http::init()
->groups(['api'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('response')
->inject('project')
@@ -574,17 +566,12 @@ Http::init()
->inject('platform')
->inject('authorization')
->inject('cacheControlForStorage')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
->action(function (Route $route, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Context $usage, FunctionPublisher $publisherForFunctions, Database $dbForProject, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
$response->setUser($user);
$request->setUser($user);
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
}
$path = $route->getMatchedPath();
$path = $route->getPath();
$databaseType = match (true) {
str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB,
str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB,
@@ -615,7 +602,7 @@ Http::init()
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
if (empty($user->getAttribute('type'))) {
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTOR_TYPE_ADMIN : ACTOR_TYPE_USER);
}
$auditContext->user = $userClone;
}
@@ -623,9 +610,8 @@ Http::init()
$useCache = $route->getLabel('cache', false);
$storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load');
if ($useCache) {
$route = $utopia->match($request);
$roles = $authorization->getRoles();
$isAppUser = $user->isApp($roles);
$isAppUser = $user->isKey($roles);
$isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview';
$isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($roles);
@@ -761,12 +747,11 @@ Http::init()
*/
Http::shutdown()
->groups(['session'])
->inject('utopia')
->inject('request')
->inject('response')
->inject('project')
->inject('dbForProject')
->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) {
->action(function (Request $request, Response $response, Document $project, Database $dbForProject) {
$sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0;
if ($sessionLimit === 0) {
@@ -800,7 +785,7 @@ Http::shutdown()
Http::shutdown()
->groups(['api'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('response')
->inject('project')
@@ -820,7 +805,7 @@ Http::shutdown()
->inject('bus')
->inject('apiKey')
->inject('mode')
->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
->action(function (Route $route, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, FunctionPublisher $publisherForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -876,7 +861,6 @@ Http::shutdown()
}
}
$route = $utopia->getRoute();
$requestParams = $route->getParamsValues();
/**
@@ -929,7 +913,7 @@ Http::shutdown()
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
if (empty($user->getAttribute('type'))) {
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER);
$userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTOR_TYPE_ADMIN : ACTOR_TYPE_USER);
}
$auditContext->user = $userClone;
} elseif ($auditContext->user === null || $auditContext->user->isEmpty()) {
@@ -944,7 +928,7 @@ Http::shutdown()
$user = new User([
'$id' => '',
'status' => true,
'type' => ACTIVITY_TYPE_GUEST,
'type' => ACTOR_TYPE_GUEST,
'email' => 'guest.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => 'Guest',
+4 -5
View File
@@ -9,6 +9,7 @@ use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Http\Http;
use Utopia\Http\Route;
use Utopia\System\System;
Http::init()
@@ -32,13 +33,13 @@ Http::init()
Http::init()
->groups(['auth'])
->inject('utopia')
->inject('route')
->inject('request')
->inject('project')
->inject('geodb')
->inject('user')
->inject('authorization')
->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) {
->action(function (Route $route, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) {
$denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', '');
if (!empty($denylist && $project->getId() === 'console')) {
$countries = explode(',', $denylist);
@@ -49,10 +50,8 @@ Http::init()
}
}
$route = $utopia->match($request);
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
$isAppUser = $user->isKey($authorization->getRoles());
if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs
return;
+2 -2
View File
@@ -539,7 +539,7 @@ $swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoo
$app->run($request, $response);
$route = $app->getRoute();
$route = $app->match($request)?->route;
Span::add('http.path', $route?->getPath() ?? 'unknown');
} catch (\Throwable $th) {
Span::error($th);
@@ -555,7 +555,7 @@ $swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoo
// All good, user is optional information for logger
}
$route = $app->getRoute();
$route = $app->match($request)?->route;
$log = $app->context()->get("log");
+7 -7
View File
@@ -159,14 +159,14 @@ const SESSION_PROVIDER_TOKEN = 'token';
const SESSION_PROVIDER_SERVER = 'server';
/**
* Activity associated with user or the app.
* Actor that performed the request (user, admin, guest, or API key).
*/
const ACTIVITY_TYPE_USER = 'user';
const ACTIVITY_TYPE_ADMIN = 'admin';
const ACTIVITY_TYPE_GUEST = 'guest';
const ACTIVITY_TYPE_KEY_PROJECT = 'keyProject';
const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount';
const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization';
const ACTOR_TYPE_USER = 'user';
const ACTOR_TYPE_ADMIN = 'admin';
const ACTOR_TYPE_GUEST = 'guest';
const ACTOR_TYPE_KEY_PROJECT = 'keyProject';
const ACTOR_TYPE_KEY_ACCOUNT = 'keyAccount';
const ACTOR_TYPE_KEY_ORGANIZATION = 'keyOrganization';
/**
* MFA
+1 -1
View File
@@ -266,7 +266,7 @@ Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIS
Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME));
Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_LIST, 'deployments', Response::MODEL_DEPLOYMENT));
Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION));
Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false));
Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, true));
Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, true));
Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true));
Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false));
+2 -2
View File
@@ -596,7 +596,7 @@ return function (Container $context): void {
// These endpoints moved from /v1/projects/:projectId/<resource> to /v1/<resource>
// When accessed via the old alias path, extract projectId from the URI
$deprecatedProjectPathPrefix = '/v1/projects/';
$route = $utopia->match($request);
$route = $utopia->match($request)?->route;
if (!empty($route)) {
$isDeprecatedAlias = \str_starts_with($request->getURI(), $deprecatedProjectPathPrefix) &&
!\str_starts_with($route->getPath(), $deprecatedProjectPathPrefix);
@@ -1093,7 +1093,7 @@ return function (Container $context): void {
if ($project->getId() !== 'console') {
$teamInternalId = $project->getAttribute('teamInternalId', '');
} else {
$route = $utopia->match($request);
$route = $utopia->match($request)?->route;
$path = ! empty($route) ? $route->getPath() : $request->getURI();
$orgHeader = $request->getHeader('x-appwrite-organization', '');
if (str_starts_with($path, '/v1/projects/:projectId')) {
+1 -1
View File
@@ -858,7 +858,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true;
if (
!$websocketEnabled
&& !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles()))
&& !($user->isPrivileged($authorization->getRoles()) || $user->isKey($authorization->getRoles()))
) {
throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED);
}
+2 -2
View File
@@ -54,7 +54,7 @@
"utopia-php/abuse": "1.3.*",
"utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
"utopia-php/audit": "2.3.*",
"utopia-php/audit": "^2.4",
"utopia-php/auth": "0.5.*",
"utopia-php/cache": "^3.0",
"utopia-php/cli": "0.23.*",
@@ -67,7 +67,7 @@
"utopia-php/emails": "0.7.*",
"utopia-php/dns": "1.7.*",
"utopia-php/dsn": "0.2.1",
"utopia-php/http": "^2.0@RC",
"utopia-php/http": "2.0.0-rc2",
"utopia-php/fetch": "^1.1",
"utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
Generated
+49 -130
View File
@@ -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": "b645c7da1728536497fe8186b158f9c6",
"content-hash": "b092fffec11494aea10b0c823b7837b8",
"packages": [
{
"name": "adhocore/jwt",
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
"version": "0.20.0",
"version": "0.20.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
"reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3"
"reference": "e9213dfe9fff1b67de77aa61dbcae5f4ca10b6d6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/e9213dfe9fff1b67de77aa61dbcae5f4ca10b6d6",
"reference": "e9213dfe9fff1b67de77aa61dbcae5f4ca10b6d6",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
"source": "https://github.com/appwrite/runtimes/tree/0.20.0"
"source": "https://github.com/appwrite/runtimes/tree/0.20.1"
},
"time": "2026-05-01T07:47:07+00:00"
"time": "2026-05-24T03:00:39+00:00"
},
{
"name": "brick/math",
@@ -3510,16 +3510,16 @@
},
{
"name": "utopia-php/audit",
"version": "2.3.2",
"version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
"reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3"
"reference": "eddd79d93f23ed2851c0df2b1e2e2dfb25ba06c6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/audit/zipball/e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3",
"reference": "e7b4049fc2ee9be34bcc18771fa593db3b0e9fe3",
"url": "https://api.github.com/repos/utopia-php/audit/zipball/eddd79d93f23ed2851c0df2b1e2e2dfb25ba06c6",
"reference": "eddd79d93f23ed2851c0df2b1e2e2dfb25ba06c6",
"shasum": ""
},
"require": {
@@ -3554,9 +3554,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/audit/issues",
"source": "https://github.com/utopia-php/audit/tree/2.3.2"
"source": "https://github.com/utopia-php/audit/tree/2.4.1"
},
"time": "2026-05-14T04:00:37+00:00"
"time": "2026-05-20T06:25:45+00:00"
},
{
"name": "utopia-php/auth",
@@ -4245,16 +4245,16 @@
},
{
"name": "utopia-php/emails",
"version": "0.7.0",
"version": "0.7.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/emails.git",
"reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107"
"reference": "a5f1d111e5023918731f2de96d348f5b6a0de143"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/emails/zipball/115e24aa908e2b1f06c7ff3b94434a0bdbed9107",
"reference": "115e24aa908e2b1f06c7ff3b94434a0bdbed9107",
"url": "https://api.github.com/repos/utopia-php/emails/zipball/a5f1d111e5023918731f2de96d348f5b6a0de143",
"reference": "a5f1d111e5023918731f2de96d348f5b6a0de143",
"shasum": ""
},
"require": {
@@ -4300,9 +4300,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/emails/issues",
"source": "https://github.com/utopia-php/emails/tree/0.7.0"
"source": "https://github.com/utopia-php/emails/tree/0.7.1"
},
"time": "2026-05-13T05:01:26+00:00"
"time": "2026-05-20T13:05:30+00:00"
},
{
"name": "utopia-php/fetch",
@@ -4346,16 +4346,16 @@
},
{
"name": "utopia-php/http",
"version": "2.0.0-rc1",
"version": "2.0.0-rc2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
"reference": "3e3b431d443844c6bf810120dee735f45880856f"
"reference": "17f3d5e966ada8a5c041717436f069f269aef2b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/http/zipball/3e3b431d443844c6bf810120dee735f45880856f",
"reference": "3e3b431d443844c6bf810120dee735f45880856f",
"url": "https://api.github.com/repos/utopia-php/http/zipball/17f3d5e966ada8a5c041717436f069f269aef2b3",
"reference": "17f3d5e966ada8a5c041717436f069f269aef2b3",
"shasum": ""
},
"require": {
@@ -4396,9 +4396,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
"source": "https://github.com/utopia-php/http/tree/2.0.0-rc1"
"source": "https://github.com/utopia-php/http/tree/2.0.0-rc2"
},
"time": "2026-05-05T15:00:03+00:00"
"time": "2026-05-20T11:13:49+00:00"
},
{
"name": "utopia-php/image",
@@ -5355,16 +5355,16 @@
},
{
"name": "utopia-php/validators",
"version": "0.2.3",
"version": "0.2.4",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/validators.git",
"reference": "9770269c8ed8e6909934965fa8722103c7434c23"
"reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/validators/zipball/9770269c8ed8e6909934965fa8722103c7434c23",
"reference": "9770269c8ed8e6909934965fa8722103c7434c23",
"url": "https://api.github.com/repos/utopia-php/validators/zipball/b4ee60db4dbae5ffbe53968d01f69b6941251576",
"reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576",
"shasum": ""
},
"require": {
@@ -5394,9 +5394,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/validators/issues",
"source": "https://github.com/utopia-php/validators/tree/0.2.3"
"source": "https://github.com/utopia-php/validators/tree/0.2.4"
},
"time": "2026-05-14T08:05:44+00:00"
"time": "2026-05-21T12:47:43+00:00"
},
{
"name": "utopia-php/vcs",
@@ -5638,16 +5638,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "1.31.0",
"version": "1.31.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "a7119db15696131a86d477b3bed348beda85523f"
"reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a7119db15696131a86d477b3bed348beda85523f",
"reference": "a7119db15696131a86d477b3bed348beda85523f",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/5699f6da951aef9378fabdcf12f40a9a54fb3128",
"reference": "5699f6da951aef9378fabdcf12f40a9a54fb3128",
"shasum": ""
},
"require": {
@@ -5656,7 +5656,7 @@
"ext-mbstring": "*",
"matthiasmullie/minify": "1.3.*",
"php": ">=8.3",
"twig/twig": "3.14.*"
"twig/twig": "3.26.*"
},
"require-dev": {
"brianium/paratest": "7.*",
@@ -5683,9 +5683,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
"source": "https://github.com/appwrite/sdk-generator/tree/1.31.0"
"source": "https://github.com/appwrite/sdk-generator/tree/1.31.1"
},
"time": "2026-05-20T11:16:09+00:00"
"time": "2026-05-20T22:22:59+00:00"
},
{
"name": "brianium/paratest",
@@ -8213,86 +8213,6 @@
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-php81",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php81\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.11",
@@ -8549,26 +8469,27 @@
},
{
"name": "twig/twig",
"version": "3.14.x-dev",
"version": "v3.26.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a"
"reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a",
"reference": "0b6f9d8370bb3b7f1ce5313ed8feb0fafd6e399a",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/1fcae487b180d78e6351f4e0afa91f9eab96a2bc",
"reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc",
"shasum": ""
},
"require": {
"php": ">=8.0.2",
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3",
"symfony/polyfill-php81": "^1.29"
"symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
"php-cs-fixer/shim": "^3.0@stable",
"phpstan/phpstan": "^2.0@stable",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
},
@@ -8612,7 +8533,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.14.2"
"source": "https://github.com/twigphp/Twig/tree/v3.26.0"
},
"funding": [
{
@@ -8624,14 +8545,12 @@
"type": "tidelift"
}
],
"time": "2024-11-07T12:36:22+00:00"
"time": "2026-05-20T07:31:59+00:00"
}
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"utopia-php/http": 5
},
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
-1
View File
@@ -1 +0,0 @@
Create a new JWT token. This token can be used to authenticate users with custom scopes and expiration time.
@@ -1 +0,0 @@
Send a test email to verify SMTP configuration.
-1
View File
@@ -1 +0,0 @@
Create a new project. You can create a maximum of 100 projects per account.
@@ -1 +0,0 @@
Reset a custom email template to its default value. This endpoint removes any custom content and restores the template to its original state.
-1
View File
@@ -1 +0,0 @@
Delete a project by its unique ID.
@@ -1 +0,0 @@
Get a custom email template for the specified locale and type. This endpoint returns the template content, subject, and other configuration details.
-1
View File
@@ -1 +0,0 @@
Get a project by its unique ID. This endpoint allows you to retrieve the project's details, including its name, description, team, region, and other metadata.
@@ -1 +0,0 @@
Update the status of a specific authentication method. Use this endpoint to enable or disable different authentication methods such as email, magic urls or sms in your project.
@@ -1 +0,0 @@
Update a custom email template for the specified locale and type. Use this endpoint to modify the content of your email templates.
@@ -1 +0,0 @@
Update the list of mock phone numbers for testing. Use these numbers to bypass SMS verification in development.
@@ -1 +0,0 @@
Update the OAuth2 provider configurations. Use this endpoint to set up or update the OAuth2 provider credentials or enable/disable providers.
-1
View File
@@ -1 +0,0 @@
Update the SMTP configuration for your project. Use this endpoint to configure your project's SMTP provider with your custom settings for sending transactional emails.
-1
View File
@@ -1 +0,0 @@
Update a project by its unique ID.
+3 -3
View File
@@ -122,9 +122,9 @@ class Key
$secret = $key;
}
$role = User::ROLE_APPS;
$role = User::ROLE_KEYS;
$roles = Config::getParam('roles', []);
$scopes = $roles[User::ROLE_APPS]['scopes'] ?? [];
$scopes = $roles[User::ROLE_KEYS]['scopes'] ?? [];
$expired = false;
$guestKey = new Key(
@@ -270,7 +270,7 @@ class Key
$name = $key->getAttribute('name', 'UNKNOWN');
$role = User::ROLE_APPS;
$role = User::ROLE_KEYS;
$scopes = $key->getAttribute('scopes', []);
+2 -8
View File
@@ -342,7 +342,6 @@ class Resolvers
$lock->acquire();
$original = $utopia->getRoute();
try {
$request = clone $request;
$request->addHeader('x-appwrite-source', 'graphql');
@@ -363,10 +362,9 @@ class Resolvers
$resolverResponse->setContentType(Response::CONTENT_TYPE_NULL);
$resolverResponse->setSent(false);
$route = $utopia->match($request, fresh: true);
$request->setRoute($route);
$request->setRoute($utopia->match($request)?->route);
$utopia->execute($route, $request, $resolverResponse);
$utopia->execute($request, $resolverResponse);
self::mergeResponseSideEffects($resolverResponse, $response);
@@ -385,10 +383,6 @@ class Resolvers
$reject($e);
return;
} finally {
if ($original !== null) {
$utopia->setRoute($original);
}
$lock->release();
unset(self::$locks[\spl_object_hash($utopia)]);
}
+2
View File
@@ -11,6 +11,7 @@ use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Health;
use Appwrite\Platform\Modules\Migrations;
use Appwrite\Platform\Modules\Organization;
use Appwrite\Platform\Modules\Presences;
use Appwrite\Platform\Modules\Project;
use Appwrite\Platform\Modules\Projects;
@@ -44,6 +45,7 @@ class Appwrite extends Platform
$this->addModule(new VCS\Module());
$this->addModule(new Webhooks\Module());
$this->addModule(new Migrations\Module());
$this->addModule(new Organization\Module());
$this->addModule(new Project\Module());
$this->addModule(new Advisor\Module());
}
@@ -93,7 +93,7 @@ class Decrement extends Action
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -93,7 +93,7 @@ class Increment extends Action
public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void
{
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -201,7 +201,7 @@ class Create extends Action
$documents = [$data];
}
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($isBulk && !$isAPIKey && !$isPrivilegedUser) {
@@ -107,7 +107,7 @@ class Delete extends Action
): void {
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -78,7 +78,7 @@ class Get extends Action
public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, User $user): void
{
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -103,7 +103,7 @@ class Update extends Action
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) {
@@ -108,7 +108,7 @@ class Upsert extends Action
throw new Exception($this->getMissingPayloadException());
}
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -88,7 +88,7 @@ class XList extends Action
public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void
{
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId));
@@ -75,7 +75,7 @@ class Create extends Action
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty');
}
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
// API keys and admins can read any transaction, regular users need permissions
@@ -120,7 +120,7 @@ class Update extends Action
throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time');
}
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$transaction = ($isAPIKey || $isPrivilegedUser)
@@ -227,6 +227,7 @@ class Create extends Action
}
if ($completed) {
$queueForEvents->reset();
return;
}
@@ -249,6 +250,8 @@ class Create extends Action
$metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata);
if ($uploaded === $chunks) {
$queueForEvents->reset();
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($deployment, Response::MODEL_DEPLOYMENT);
@@ -161,7 +161,7 @@ class Create extends Base
/* @var Document $function */
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -67,7 +67,7 @@ class Get extends Base
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -77,7 +77,7 @@ class XList extends Base
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -95,6 +95,8 @@ class Create extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true)
->param('providerBranches', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
->param('providerPaths', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -147,6 +149,8 @@ class Create extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
array $providerBranches,
array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
string $templateRepository,
@@ -248,6 +252,8 @@ class Create extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
'providerBranches' => $providerBranches,
'providerPaths' => $providerPaths,
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
]));
@@ -87,6 +87,8 @@ class Update extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the function? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to function code in the linked repo.', true)
->param('providerBranches', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
->param('providerPaths', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -132,6 +134,8 @@ class Update extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
?array $providerBranches,
?array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
int $deploymentRetention,
@@ -276,6 +280,8 @@ class Update extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
'providerBranches' => $providerBranches ?? $function->getAttribute('providerBranches', []),
'providerPaths' => $providerPaths ?? $function->getAttribute('providerPaths', []),
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
'search' => implode(' ', [$functionId, $name, $runtime]),
@@ -104,8 +104,6 @@ class Builds extends Action
Executor $executor,
array $plan
): void {
Console::log('Build action started');
$payload = $message->getPayload();
if (empty($payload)) {
@@ -113,6 +111,8 @@ class Builds extends Action
}
$type = $payload['type'] ?? '';
Span::add('build.type', $type);
$resource = new Document($payload['resource'] ?? []);
$deployment = new Document($payload['deployment'] ?? []);
$template = new Document($payload['template'] ?? []);
@@ -124,7 +124,6 @@ class Builds extends Action
switch ($type) {
case BUILD_TYPE_DEPLOYMENT:
case BUILD_TYPE_RETRY:
Console::info('Creating build for deployment: ' . $deployment->getId());
$github = new GitHub($cache);
$this->buildDeployment(
$deviceForFunctions,
@@ -193,8 +192,6 @@ class Builds extends Action
Span::add('deployment.id', $deployment->getId());
Span::add('build.timeout', $timeout);
Console::info('Deployment action started');
$startTime = DateTime::now();
$durationStart = \microtime(true);
@@ -268,7 +265,7 @@ class Builds extends Action
$resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')]));
}
Console::log('Status marked as processing');
Span::add('deployment.status', 'processing');
$queueForRealtime
->setPayload($deployment->getArrayCopy())
@@ -359,7 +356,7 @@ class Builds extends Action
->setPayload($deployment->getArrayCopy())
->trigger();
Console::log('Template cloned');
Span::add('build.source_size', $deployment->getAttribute('sourceSize'));
}
} elseif ($isVcsEnabled) {
// VCS and VCS+Temaplte
@@ -403,8 +400,6 @@ class Builds extends Action
throw new \Exception('Unable to clone code repository: ' . $stderr);
}
Console::log('Git repository cloned');
// Local refactoring for function folder with spaces
if (str_contains($rootDirectory, ' ')) {
$rootDirectoryWithoutSpaces = str_replace(' ', '', $rootDirectory);
@@ -478,8 +473,6 @@ class Builds extends Action
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();
Console::log('Git template pushed');
}
$tmpPath = '/tmp/builds/' . $deploymentId;
@@ -531,18 +524,17 @@ class Builds extends Action
->setPayload($deployment->getArrayCopy())
->trigger();
Console::log('Git source uploaded');
Span::add('build.source_size', $deployment->getAttribute('sourceSize'));
$this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform);
}
Console::log('Status marked as building');
/** Request the executor to build the code... */
$deployment->setAttribute('status', 'building');
$deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), new Document([
'status' => 'building',
]));
Span::add('deployment.status', 'building');
if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) {
$resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')]));
@@ -687,11 +679,10 @@ class Builds extends Action
}
$isCanceled = false;
Console::log('Runtime creation started');
$span = Span::current();
Co::join([
Co\go(function () use ($executor, &$response, $project, $deployment, $source, $resource, $runtime, $vars, $command, $cpus, $memory, $timeout, &$err, $version) {
Co\go(function () use ($executor, &$response, $project, $deployment, $source, $resource, $runtime, $vars, $command, $cpus, $memory, $timeout, &$err, $version, $span) {
try {
if ($version === 'v2') {
$command = 'tar -zxf /tmp/code.tar.gz -C /usr/code && cd /usr/local/src/ && ./build.sh';
@@ -739,16 +730,18 @@ class Builds extends Action
outputDirectory: $outputDirectory ?? ''
);
Console::log('createRuntime finished');
} catch (ExecutorTimeout $error) {
Console::warning('createRuntime timed out');
$span?->set('build.runtime.timed_out', true);
$span?->set('build.runtime.error_type', $error::class);
$span?->set('build.runtime.error_message', $error->getMessage());
$err = new AppwriteException(AppwriteException::BUILD_TIMEOUT, previous: $error);
} catch (\Throwable $error) {
Console::warning('createRuntime failed');
$span?->set('build.runtime.error_type', $error::class);
$span?->set('build.runtime.error_message', $error->getMessage());
$err = $error;
}
}),
Co\go(function () use ($executor, $project, &$deployment, &$response, $dbForProject, $timeout, &$err, $queueForRealtime, &$isCanceled) {
Co\go(function () use ($executor, $project, &$deployment, &$response, $dbForProject, $timeout, &$err, $queueForRealtime, &$isCanceled, $span) {
try {
$insideSeparation = false;
@@ -756,7 +749,7 @@ class Builds extends Action
deploymentId: $deployment->getId(),
projectId: $project->getId(),
timeout: $timeout,
callback: function ($logs) use (&$response, &$err, $dbForProject, &$isCanceled, &$deployment, $queueForRealtime, &$insideSeparation) {
callback: function ($logs) use (&$response, &$err, $dbForProject, &$isCanceled, &$deployment, $queueForRealtime, &$insideSeparation, $span) {
if ($isCanceled) {
return;
}
@@ -767,7 +760,7 @@ class Builds extends Action
if ($deployment->getAttribute('status') === 'canceled') {
$isCanceled = true;
Console::info('Ignoring realtime logs because build has been canceled');
$span?->set('build.logs.ignored_reason', 'canceled');
return;
}
@@ -836,9 +829,10 @@ class Builds extends Action
}
}
);
Console::warning('listLogs finished');
$span?->set('build.logs.finished', true);
} catch (\Throwable $error) {
Console::warning('listLogs failed');
$span?->set('build.logs.error_type', $error::class);
$span?->set('build.logs.error_message', $error->getMessage());
if (empty($err)) {
$err = $error;
}
@@ -846,8 +840,6 @@ class Builds extends Action
}),
]);
Console::log('Runtime creation finished');
$latestDeployment = $dbForProject->getDocument('deployments', $deploymentId);
if ($latestDeployment->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
@@ -870,6 +862,8 @@ class Builds extends Action
$deployment->setAttribute('buildPath', $response['path']);
$deployment->setAttribute('buildSize', $response['size']);
$deployment->setAttribute('totalSize', $deployment->getAttribute('buildSize', 0) + $deployment->getAttribute('sourceSize', 0));
Span::add('build.size', $deployment->getAttribute('buildSize'));
Span::add('build.total_size', $deployment->getAttribute('totalSize'));
$logs = '';
foreach ($response['output'] as $log) {
@@ -908,8 +902,8 @@ class Builds extends Action
$deployment->setAttribute('adapter', $detection->getName());
$deployment->setAttribute('fallbackFile', $detection->getFallbackFile() ?? '');
Console::log('Adapter detected');
Span::add('build.adapter', $deployment->getAttribute('adapter'));
Span::add('build.fallback_file', $deployment->getAttribute('fallbackFile'));
} elseif ($adapter === 'ssr' && $detection->getName() === 'static') {
throw new \Exception('Adapter mismatch. Detected: ' . $detection->getName() . ' does not match with the set adapter: ' . $adapter);
}
@@ -927,8 +921,6 @@ class Builds extends Action
->setPayload($deployment->getArrayCopy())
->trigger();
Console::log('Build details stored');
$this->afterBuildSuccess($queueForRealtime, $dbForProject, $deployment, $runtime, $adapter);
$logs = $deployment->getAttribute('buildLogs', '');
@@ -942,8 +934,7 @@ class Builds extends Action
'buildLogs' => $deployment->getAttribute('buildLogs'),
'status' => 'ready',
]));
Console::log('Status marked as ready');
Span::add('deployment.status', 'ready');
if ($deployment->getSequence() === $resource->getAttribute('latestDeploymentInternalId', '')) {
$resource = $dbForProject->updateDocument($resource->getCollection(), $resource->getId(), new Document(['latestDeploymentStatus' => $deployment->getAttribute('status', '')]));
@@ -969,7 +960,7 @@ class Builds extends Action
if ($currentActiveStartTime < $deploymentStartTime) {
$activateBuild = true;
} else {
Console::info('Skipping auto-activation as current deployment is more recent');
Span::add('build.auto_activation.skipped_reason', 'current_deployment_newer');
}
}
} else {
@@ -1031,7 +1022,7 @@ class Builds extends Action
break;
}
Console::log('Deployment activated');
Span::add('build.activated', true);
}
$this->afterDeploymentSuccess(
@@ -1099,7 +1090,7 @@ class Builds extends Action
]));
}, $queries);
Console::log('Preview rule created');
Span::add('build.preview_rule_created', true);
}
}
@@ -1109,6 +1100,7 @@ class Builds extends Action
'buildEndedAt' => $endTime,
'buildDuration' => \intval(\ceil($durationEnd - $durationStart)),
]));
Span::add('build.duration', $deployment->getAttribute('buildDuration'));
$queueForRealtime
->setPayload($deployment->getArrayCopy())
->trigger();
@@ -1119,8 +1111,6 @@ class Builds extends Action
return;
}
Console::log('Build duration updated');
/** Update function schedule */
// Inform scheduler if function is still active
@@ -1144,23 +1134,21 @@ class Builds extends Action
deploymentId: $deployment->getId(),
));
Console::log('Site screenshot queued');
Span::add('build.screenshot_queued', true);
}
Console::info('Deployment action finished');
} catch (\Throwable $th) {
Console::warning('Build failed:');
Console::error($th->getMessage());
Console::error($th->getFile());
Console::error($th->getLine());
Console::error($th->getTraceAsString());
if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') {
$this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime);
return;
}
Span::add('build.error.stage', 'deployment');
Span::add('build.error.type', $th::class);
Span::add('build.error.message', $th->getMessage());
Span::add('build.error.file', $th->getFile());
Span::add('build.error.line', $th->getLine());
// Color message red
$message = $th->getMessage();
if (! \str_contains($message, '')) {
@@ -1182,6 +1170,8 @@ class Builds extends Action
$deployment->setAttribute('buildEndedAt', $endTime);
$deployment->setAttribute('buildDuration', \intval(\ceil($durationEnd - $durationStart)));
$deployment->setAttribute('status', 'failed');
Span::add('deployment.status', 'failed');
Span::add('build.duration', $deployment->getAttribute('buildDuration'));
$deployment->setAttribute('buildLogs', $message);
$deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([
@@ -1200,7 +1190,7 @@ class Builds extends Action
->trigger();
if ($isVcsEnabled) {
$this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform);
$this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime, $platform, true);
}
} finally {
$queueForRealtime
@@ -1360,7 +1350,8 @@ class Builds extends Action
Database $dbForProject,
Database $dbForPlatform,
Realtime $queueForRealtime,
array $platform
array $platform,
bool $secondaryError = false
): void {
$deployment = new Document();
@@ -1456,9 +1447,14 @@ class Builds extends Action
}
}
} catch (\Throwable $th) {
Console::warning('Git action failed:');
Console::warning($th->getMessage());
Console::warning($th->getTraceAsString());
$span = Span::current();
$errorPrefix = $secondaryError ? 'build.error.secondary' : 'build.git_action.error';
$span?->set("{$errorPrefix}.stage", 'git_action');
$span?->set("{$errorPrefix}.status", $status);
$span?->set("{$errorPrefix}.type", $th::class);
$span?->set("{$errorPrefix}.message", $th->getMessage());
$span?->set("{$errorPrefix}.file", $th->getFile());
$span?->set("{$errorPrefix}.line", $th->getLine());
$logs = $deployment->getAttribute('buildLogs', '');
$date = \date('H:i:s');
@@ -1477,7 +1473,7 @@ class Builds extends Action
private function cancelDeployment(string $deploymentId, Database $dbForProject, Realtime $queueForRealtime)
{
Console::info('Build has been canceled');
Span::add('deployment.status', 'canceled');
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
@@ -0,0 +1,28 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http;
use Appwrite\Extend\Exception;
use Utopia\Database\Document;
use Utopia\Platform\Action;
class Init extends Action
{
public static function getName(): string
{
return 'init';
}
public function __construct()
{
$this
->setType(Action::TYPE_INIT)
->groups(['organization'])
->inject('team')
->callback(function (Document $team) {
if ($team->isEmpty()) {
throw new Exception(Exception::TEAM_NOT_FOUND);
}
});
}
}
@@ -0,0 +1,11 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Platform\Action as AppwriteAction;
use Appwrite\Platform\Permission as AppwritePermission;
class Action extends AppwriteAction
{
use AppwritePermission;
}
@@ -0,0 +1,233 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\ProjectId;
use Appwrite\Utopia\Response;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Helpers\ID;
use Utopia\DSN\DSN;
use Utopia\Platform\Scope\HTTP;
use Utopia\Pools\Group;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createOrganizationProject';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/organization/projects')
->desc('Create organization project')
->groups(['api', 'organization'])
->label('audits.event', 'projects.create')
->label('audits.resource', 'project/{response.$id}')
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'organization',
group: 'projects',
name: 'createProject',
description: <<<EOT
Create a new project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_PROJECT,
)
],
contentType: ContentType::JSON
))
->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.')
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true)
->inject('response')
->inject('dbForPlatform')
->inject('cache')
->inject('pools')
->inject('hooks')
->inject('team')
->callback($this->action(...));
}
public function action(string $projectId, string $name, string $region, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks, Document $team)
{
$allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', '')));
if (!empty($allowList) && !\in_array($region, $allowList)) {
throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported');
}
$auth = Config::getParam('auth', []);
$auths = [
'limit' => 0,
'maxSessions' => 0,
'passwordHistory' => 0,
'passwordDictionary' => false,
'duration' => TOKEN_EXPIRATION_LOGIN_LONG,
'personalDataCheck' => false,
'disposableEmails' => false,
'canonicalEmails' => false,
'freeEmails' => false,
'mockNumbers' => [],
'sessionAlerts' => false,
'membershipsUserName' => false,
'membershipsUserEmail' => false,
'membershipsMfa' => false,
'membershipsUserId' => false,
'membershipsUserPhone' => false,
'invalidateSessions' => true
];
foreach ($auth as $method) {
$auths[$method['key'] ?? ''] = true;
}
$projectId = ($projectId == 'unique()') ? ID::unique() : $projectId;
if ($projectId === 'console') {
throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project.");
}
$databases = Config::getParam('pools-database', []);
if ($region !== 'default') {
$databaseKeys = System::getEnv('_APP_DATABASE_KEYS', '');
$keys = explode(',', $databaseKeys);
$databases = array_filter($keys, function ($value) use ($region) {
return str_contains($value, $region);
});
}
$databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE');
$index = \array_search($databaseOverride, $databases);
if ($index !== false) {
$dsn = $databases[$index];
} else {
$dsn = $databases[array_rand($databases)];
}
// TODO: Temporary until all projects are using shared tables.
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn, $sharedTables)) {
$schema = 'appwrite';
$database = 'appwrite';
$namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', '');
$dsn = $schema . '://' . $dsn . '?database=' . $database;
if (!empty($namespace)) {
$dsn .= '&namespace=' . $namespace;
}
}
try {
$project = $dbForPlatform->createDocument('projects', new Document([
'$id' => $projectId,
'$permissions' => $this->getPermissions($team->getId(), $projectId),
'name' => $name,
'teamInternalId' => $team->getSequence(),
'teamId' => $team->getId(),
'region' => $region,
'version' => APP_VERSION_STABLE,
'services' => new \stdClass(),
'platforms' => null,
'oAuthProviders' => [],
'webhooks' => null,
'keys' => null,
'auths' => $auths,
'accessedAt' => DateTime::now(),
'search' => implode(' ', [$projectId, $name]),
'database' => $dsn,
'labels' => [],
'status' => PROJECT_STATUS_ACTIVE,
]));
} catch (Duplicate) {
throw new Exception(Exception::PROJECT_ALREADY_EXISTS);
}
try {
$dsn = new DSN($dsn);
} catch (\InvalidArgumentException) {
// TODO: Temporary until all projects are using shared tables
$dsn = new DSN('mysql://' . $dsn);
}
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
$projectTables = !\in_array($dsn->getHost(), $sharedTables);
if ($projectTables) {
$adapter = new DatabasePool($pools->get($dsn->getHost()));
$dbForProject = new Database($adapter, $cache);
$dbForProject
->setDatabase(APP_DATABASE)
->setSharedTables(false)
->setTenant(null)
->setNamespace('_' . $project->getSequence());
$create = true;
try {
$dbForProject->create();
} catch (Duplicate) {
$create = false;
}
$adapter = new AdapterDatabase($dbForProject);
$audit = new Audit($adapter);
$audit->setup();
if ($create) {
/** @var array $collections */
$collections = Config::getParam('collections', [])['projects'] ?? [];
foreach ($collections as $key => $collection) {
if (($collection['$collection'] ?? '') !== Database::METADATA) {
continue;
}
$attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']);
$indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']);
try {
$dbForProject->createCollection($key, $attributes, $indexes);
} catch (Duplicate) {
// Collection already exists
}
}
}
}
// Hook allowing instant project mirroring during migration
// Outside of migration, hook is not registered and has no effect
$hooks->trigger('afterProjectCreation', [$project, $pools, $cache]);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($project, Response::MODEL_PROJECT);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Event\Message\Delete as DeleteMessage;
use Appwrite\Event\Publisher\Delete as DeletePublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteOrganizationProject';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/organization/projects/:projectId')
->desc('Delete organization project')
->groups(['api', 'organization'])
->label('scope', 'projects.write')
->label('audits.event', 'projects.delete')
->label('audits.resource', 'project/{request.projectId}')
->label('sdk', new Method(
namespace: 'organization',
group: 'projects',
name: 'deleteProject',
description: <<<EOT
Delete a project by its unique ID.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('projectId', '', new UID(), 'Project unique ID.')
->inject('response')
->inject('dbForPlatform')
->inject('publisherForDeletes')
->inject('authorization')
->inject('team')
->callback($this->action(...));
}
public function action(
string $projectId,
Response $response,
Database $dbForPlatform,
DeletePublisher $publisherForDeletes,
Authorization $authorization,
Document $team,
) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
if ($project->getAttribute('teamInternalId') !== $team->getSequence()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
}
$publisherForDeletes->enqueue(new DeleteMessage(
project: $project,
type: DELETE_TYPE_DOCUMENT,
document: $project,
));
$response->noContent();
}
}
@@ -0,0 +1,76 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getOrganizationProject';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/organization/projects/:projectId')
->desc('Get organization project')
->groups(['api', 'organization'])
->label('scope', 'projects.read')
->label('sdk', new Method(
namespace: 'organization',
group: 'projects',
name: 'getProject',
description: <<<EOT
Get a project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
],
contentType: ContentType::NONE
))
->param('projectId', '', new UID(), 'Project unique ID.')
->inject('response')
->inject('dbForPlatform')
->inject('team')
->callback($this->action(...));
}
public function action(
string $projectId,
Response $response,
Database $dbForPlatform,
Document $team,
) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
if ($project->getAttribute('teamInternalId') !== $team->getSequence()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic($project, Response::MODEL_PROJECT);
}
}
@@ -0,0 +1,81 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateOrganizationProject';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/organization/projects/:projectId')
->desc('Update organization project')
->groups(['api', 'organization'])
->label('scope', 'projects.write')
->label('audits.event', 'projects.update')
->label('audits.resource', 'project/{response.$id}')
->label('sdk', new Method(
namespace: 'organization',
group: 'projects',
name: 'updateProject',
description: <<<EOT
Update a project by its unique ID.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
],
contentType: ContentType::JSON
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->inject('response')
->inject('dbForPlatform')
->inject('team')
->callback($this->action(...));
}
public function action(string $projectId, string $name, Response $response, Database $dbForPlatform, Document $team)
{
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
if ($project->getAttribute('teamInternalId') !== $team->getSequence()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
'name' => $name,
'search' => implode(' ', [$projectId, $name]),
]));
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic($project, Response::MODEL_PROJECT);
}
}
@@ -0,0 +1,196 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\ListSelection;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class XList extends Action
{
use HTTP;
// cached mapping of columns to their subQuery filters
private static ?array $attributeToSubQueryFilters = null;
public static function getName()
{
return 'listOrganizationProjects';
}
protected function getQueriesValidator(): Validator
{
return new Projects();
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/organization/projects')
->desc('List organization projects')
->groups(['api', 'organization'])
->label('scope', 'projects.read')
->label('sdk', new Method(
namespace: 'organization',
group: 'projects',
name: 'listProjects',
description: <<<EOT
Get a list of all projects. You can use the query params to filter your results.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT_LIST
)
],
contentType: ContentType::JSON
))
->param('queries', [], $this->getQueriesValidator(), '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(', ', Projects::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', 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')
->inject('team')
->callback($this->action(...));
}
public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform, Document $team)
{
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
$queries[] = Query::equal('teamInternalId', [$team->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());
}
$projectId = $cursor->getValue();
$cursorDocument = $dbForPlatform->getDocument('projects', $projectId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Project '{$projectId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
try {
$selectQueries = Query::groupByType($queries)['selections'];
$filterQueries = Query::groupByType($queries)['filters'];
$projects = $this->find($dbForPlatform, $queries, $selectQueries);
$total = $includeTotal ? $dbForPlatform->count('projects', $filterQueries, APP_LIMIT_COUNT) : 0;
} catch (Order $e) {
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.");
}
$response->addFilter(new ListSelection($selectQueries, 'projects'));
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic(new Document([
'projects' => $projects,
'total' => $total,
]), Response::MODEL_PROJECT_LIST);
}
// Build mapping of columns to their subQuery filters
private static function getAttributeToSubQueryFilters(): array
{
if (self::$attributeToSubQueryFilters !== null) {
return self::$attributeToSubQueryFilters;
}
self::$attributeToSubQueryFilters = [];
$collections = Config::getParam('collections', []);
$projectAttributes = $collections['platform']['projects']['attributes'] ?? [];
foreach ($projectAttributes as $attribute) {
$attributeId = $attribute['$id'] ?? null;
$filters = $attribute['filters'] ?? [];
if ($attributeId === null || empty($filters)) {
continue;
}
// extract only subQuery filters
$subQueryFilters = \array_filter($filters, function ($filter) {
return \str_starts_with($filter, 'subQuery');
});
if (!empty($subQueryFilters)) {
self::$attributeToSubQueryFilters[$attributeId] = \array_values($subQueryFilters);
}
}
return self::$attributeToSubQueryFilters;
}
private function find(Database $dbForPlatform, array $queries, array $selectQueries): array
{
if (empty($selectQueries)) {
return $dbForPlatform->find('projects', $queries);
}
$selectedAttributes = [];
foreach ($selectQueries as $query) {
foreach ($query->getValues() as $value) {
$selectedAttributes[] = $value;
}
}
if (\in_array('*', $selectedAttributes)) {
return $dbForPlatform->find('projects', $queries);
}
$filtersToSkipMap = [];
$selectedAttributesMap = \array_flip($selectedAttributes);
$attributeToSubQueryFilters = self::getAttributeToSubQueryFilters();
foreach ($attributeToSubQueryFilters as $attributeName => $subQueryFilters) {
if (!isset($selectedAttributesMap[$attributeName])) {
foreach ($subQueryFilters as $filter) {
$filtersToSkipMap[$filter] = true;
}
}
}
$filtersToSkip = \array_keys($filtersToSkipMap);
return empty($filtersToSkip)
? $dbForPlatform->find('projects', $queries)
: $dbForPlatform->skipFilters(fn () => $dbForPlatform->find('projects', $queries), $filtersToSkip);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Organization;
use Appwrite\Platform\Modules\Organization\Services\Http;
use Utopia\Platform\Module as Base;
class Module extends Base
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,29 @@
<?php
namespace Appwrite\Platform\Modules\Organization\Services;
use Appwrite\Platform\Modules\Organization\Http\Init as Init;
use Appwrite\Platform\Modules\Organization\Http\Projects\Create as CreateProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\Delete as DeleteProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\Get as GetProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\Update as UpdateProject;
use Appwrite\Platform\Modules\Organization\Http\Projects\XList as ListProjects;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
// Init hook
$this->addAction(Init::getName(), new Init());
// Projects
$this->addAction(CreateProject::getName(), new CreateProject());
$this->addAction(ListProjects::getName(), new ListProjects());
$this->addAction(GetProject::getName(), new GetProject());
$this->addAction(UpdateProject::getName(), new UpdateProject());
$this->addAction(DeleteProject::getName(), new DeleteProject());
}
}
@@ -128,7 +128,7 @@ class Update extends PlatformAction
Event $queueForEvents
): void {
$presenceState = new PresenceState();
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($userId && !$isAPIKey && !$isPrivilegedUser) {
@@ -128,7 +128,7 @@ class Upsert extends PlatformAction
Event $queueForEvents,
Context $usage
): void {
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($userId && !$isAPIKey && !$isPrivilegedUser) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, "userId is not allowed for non-API key and non-privileged users");
@@ -69,7 +69,7 @@ class Create extends Action
->inject('response')
->inject('project')
->inject('publisherForMails')
->inject('plan')
->inject('platform')
->callback($this->action(...));
}
@@ -89,7 +89,7 @@ class Create extends Action
Response $response,
Document $project,
MailPublisher $publisherForMails,
array $plan
array $platform,
): void {
// Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config.
// When inline params are provided they are treated as self-contained — project config is ignored
@@ -144,14 +144,7 @@ class Create extends Action
$template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl');
$template
->setParam('{{from}}', "{$senderName} ({$senderEmail})")
->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})")
->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL)
->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR)
->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER)
->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD)
->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE)
->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL)
->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL);
->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})");
foreach ($emails as $email) {
$publisherForMails->enqueue(new MailMessage(
@@ -171,6 +164,17 @@ class Create extends Action
'senderEmail' => $senderEmail,
'senderName' => $senderName,
],
variables: [
'platform' => $platform['platformName'] ?? APP_NAME,
'logoUrl' => $platform['logoUrl'] ?? APP_EMAIL_LOGO_URL,
'accentColor' => $platform['accentColor'] ?? APP_EMAIL_ACCENT_COLOR,
'twitter' => $platform['twitterUrl'] ?? APP_SOCIAL_TWITTER,
'discord' => $platform['discordUrl'] ?? APP_SOCIAL_DISCORD,
'github' => $platform['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE,
'terms' => $platform['termsUrl'] ?? APP_EMAIL_TERMS_URL,
'privacy' => $platform['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL,
],
platform: $platform,
));
}
@@ -121,7 +121,7 @@ class Update extends Action
// Validate when the caller is explicitly enabling or hasn't expressed a preference
// (so a credentials-only PATCH can auto-enable). Skip only when the caller is
// explicitly keeping/turning SMTP off.
if (\is_null($enabled) || $enabled === true) {
if ((\is_null($enabled) || $enabled === true) && !empty($smtp['senderEmail'] ?? '')) {
$mail = new PHPMailer(true);
$mail->isSMTP();
@@ -4,9 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\ProjectId;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Request;
@@ -54,19 +51,6 @@ class Create extends Action
->label('audits.event', 'projects.create')
->label('audits.resource', 'project/{response.$id}')
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'projects',
name: 'create',
description: '/docs/references/projects/create.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_PROJECT,
)
]
))
->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.')
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->param('teamId', '', new UID(), 'Team unique ID.')
@@ -3,9 +3,6 @@
namespace Appwrite\Platform\Modules\Projects\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
@@ -39,19 +36,6 @@ class Update extends Action
->label('scope', 'projects.write')
->label('audits.event', 'projects.update')
->label('audits.resource', 'project/{request.projectId}')
->label('sdk', new Method(
namespace: 'projects',
group: 'projects',
name: 'update',
description: '/docs/references/projects/update.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true)
@@ -4,10 +4,6 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Projects;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\ListSelection;
@@ -48,22 +44,6 @@ class XList extends Action
->desc('List projects')
->groups(['api', 'projects'])
->label('scope', 'projects.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'projects',
name: 'list',
description: <<<EOT
Get a list of all projects. You can use the query params to filter your results.
EOT,
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT_LIST
)
],
contentType: ContentType::JSON
))
->param('queries', [], $this->getQueriesValidator(), '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(', ', Projects::ALLOWED_ATTRIBUTES), true)
->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
@@ -227,6 +227,7 @@ class Create extends Action
}
if ($completed) {
$queueForEvents->reset();
return;
}
@@ -257,6 +258,8 @@ class Create extends Action
$metadata = \array_merge($deployment->getAttribute('sourceMetadata', []), $metadata);
if ($uploaded === $chunks) {
$queueForEvents->reset();
$response
->setStatusCode(Response::STATUS_CODE_ACCEPTED)
->dynamic($deployment, Response::MODEL_DEPLOYMENT);
@@ -19,6 +19,7 @@ use Utopia\Database\Helpers\ID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
@@ -78,6 +79,8 @@ class Create extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true)
->param('providerBranches', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
->param('providerPaths', [], new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -118,6 +121,8 @@ class Create extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
array $providerBranches,
array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
int $deploymentRetention,
@@ -173,6 +178,8 @@ class Create extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
'providerBranches' => $providerBranches,
'providerPaths' => $providerPaths,
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
'buildRuntime' => $buildRuntime,
@@ -22,7 +22,9 @@ use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
@@ -81,6 +83,8 @@ class Update extends Base
->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the site.', true)
->param('providerSilentMode', false, new Boolean(), 'Is the VCS (Version Control System) connection in silent mode for the repo linked to the site? In silent mode, comments will not be made on commits and pull requests.', true)
->param('providerRootDirectory', '', new Text(128, 0), 'Path to site code in the linked repo.', true)
->param('providerBranches', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of branch name patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all branches.', true)
->param('providerPaths', null, new Nullable(new ArrayList(new Text(128), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'List of file path patterns to trigger automatic deployments. Supports wildcards. Leave empty to deploy on all file changes.', true)
->param('buildSpecification', fn (array $plan) => $this->getDefaultSpecification($plan), fn (array $plan) => new Specification(
$plan,
Config::getParam('specifications', []),
@@ -126,6 +130,8 @@ class Update extends Base
string $providerBranch,
bool $providerSilentMode,
string $providerRootDirectory,
?array $providerBranches,
?array $providerPaths,
string $buildSpecification,
string $runtimeSpecification,
int $deploymentRetention,
@@ -271,6 +277,8 @@ class Update extends Base
'providerBranch' => $providerBranch,
'providerRootDirectory' => $providerRootDirectory,
'providerSilentMode' => $providerSilentMode,
'providerBranches' => $providerBranches ?? $site->getAttribute('providerBranches', []),
'providerPaths' => $providerPaths ?? $site->getAttribute('providerPaths', []),
'buildSpecification' => $buildSpecification,
'runtimeSpecification' => $runtimeSpecification,
'search' => implode(' ', [$siteId, $name, $framework]),
@@ -115,7 +115,7 @@ class Create extends Action
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -320,6 +320,7 @@ class Create extends Action
}
if ($completed) {
$queueForEvents->reset();
return;
}
@@ -337,6 +338,8 @@ class Create extends Action
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
}
$queueForEvents->reset();
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic($file, Response::MODEL_FILE);
@@ -84,7 +84,7 @@ class Delete extends Action
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -90,7 +90,7 @@ class Get extends Action
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -65,7 +65,7 @@ class Get extends Action
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -133,7 +133,7 @@ class Get extends Action
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -90,7 +90,7 @@ class Get extends Action
$disposition = $decoded['disposition'] ?? 'inline';
$dbForProject = $isInternal ? $dbForPlatform : $dbForProject;
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
@@ -81,7 +81,7 @@ class Update extends Action
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -110,7 +110,7 @@ class Update extends Action
// Users can only manage their own roles, API keys and Admin users can manage any
$roles = $authorization->getRoles();
if (!$user->isApp($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) {
if (!$user->isKey($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) {
foreach (Database::PERMISSIONS as $type) {
foreach ($permissions as $permission) {
$permission = Permission::parse($permission);
@@ -91,7 +91,7 @@ class Get extends Action
/* @type Document $bucket */
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -80,7 +80,7 @@ class XList extends Action
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -103,7 +103,7 @@ class Create extends Action
public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, MailPublisher $publisherForMails, MessagingPublisher $publisherForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, array $platform, Password $proofForPassword, Token $proofForToken)
{
$isAppUser = $user->isApp($authorization->getRoles());
$isAppUser = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$invitee = new Document();
$hash = '';
@@ -81,7 +81,7 @@ class Get extends Action
$roles = $authorization->getRoles();
$isPrivilegedUser = $user->isPrivileged($roles);
$isAppUser = $user->isApp($roles);
$isAppUser = $user->isKey($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
@@ -84,7 +84,7 @@ class Update extends Action
}
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
$isAppUser = $user->isKey($authorization->getRoles());
$isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner');
if ($project->getId() === 'console') {
@@ -134,7 +134,7 @@ class XList extends Action
$roles = $authorization->getRoles();
$isPrivilegedUser = $user->isPrivileged($roles);
$isAppUser = $user->isApp($roles);
$isAppUser = $user->isKey($roles);
$membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) {
return $privacy || $isPrivilegedUser || $isAppUser;
@@ -71,7 +71,7 @@ class Create extends Action
public function action(string $teamId, string $name, array $roles, Response $response, User $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents)
{
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$isAppUser = $user->isApp($authorization->getRoles());
$isAppUser = $user->isKey($authorization->getRoles());
$teamId = $teamId == 'unique()' ? ID::unique() : $teamId;
@@ -15,7 +15,7 @@ class Action extends UtopiaAction
{
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) {
@@ -130,7 +130,14 @@ class Update extends Action
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$prFiles = $github->getPullRequestFiles($owner, $providerRepositoryName, $providerPullRequestId);
$providerAffectedFiles = [
...array_column($prFiles, 'filename'),
// Only renamed files include previous_filename; skip missing values from other file changes.
...array_filter(array_column($prFiles, 'previous_filename'))
];
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$response->noContent();
}
@@ -22,6 +22,7 @@ use Utopia\DSN\DSN;
use Utopia\Span\Span;
use Utopia\System\System;
use Utopia\Validator\Contains;
use Utopia\Validator\Globstar;
use Utopia\VCS\Adapter\Git\GitHub;
use Utopia\VCS\Exception\RepositoryNotFound;
@@ -42,6 +43,7 @@ trait Deployment
string $providerCommitMessage,
string $providerCommitUrl,
string $providerPullRequestId,
array $providerAffectedFiles,
bool $external,
Database $dbForPlatform,
Authorization $authorization,
@@ -103,6 +105,32 @@ trait Deployment
continue;
}
// Skip deployments when the branch or affected files do not match configured build triggers.
$branchTrigger = new Globstar($resource->getAttribute('providerBranches', []));
if (!$branchTrigger->isValid($providerBranch)) {
Span::add("{$logBase}.build.skipped.reason", 'branch');
Span::add("{$logBase}.build.skipped", 'true');
continue;
}
$providerPaths = $resource->getAttribute('providerPaths', []);
if (!empty($providerPaths) && !empty($providerAffectedFiles)) {
$pathTrigger = new Globstar($providerPaths);
$pathMatched = false;
foreach ($providerAffectedFiles as $file) {
if ($pathTrigger->isValid($file)) {
$pathMatched = true;
break;
}
}
if (!$pathMatched) {
Span::add("{$logBase}.build.skipped.reason", 'path');
Span::add("{$logBase}.build.skipped", 'true');
continue;
}
}
$deploymentId = ID::unique();
$repositoryId = $repository->getId();
$repositoryInternalId = $repository->getSequence();
@@ -133,7 +133,6 @@ class Create extends Action
callable $getProjectDB,
array $platform,
) {
$providerBranchCreated = $parsedPayload["branchCreated"] ?? false;
$providerBranchDeleted = $parsedPayload["branchDeleted"] ?? false;
$providerBranch = $parsedPayload["branch"] ?? '';
$providerBranchUrl = $parsedPayload["branchUrl"] ?? '';
@@ -164,7 +163,8 @@ class Create extends Action
// Create new deployment only on push (not committed by us) and not when branch is deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) {
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$providerAffectedFiles = $parsedPayload['affectedFiles'] ?? [];
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', $providerAffectedFiles, false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
}
}
@@ -211,12 +211,19 @@ class Create extends Action
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
$prFiles = $github->getPullRequestFiles($providerRepositoryOwner, $providerRepositoryName, $providerPullRequestId);
$providerAffectedFiles = [
...array_column($prFiles, 'filename'),
// Only renamed files include previous_filename; skip missing values from other file changes.
...array_filter(array_column($prFiles, 'previous_filename'))
];
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
Query::equal('providerRepositoryId', [$providerRepositoryId]),
Query::orderDesc('$createdAt')
]));
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
} elseif ($action == "closed") {
// Allowed external contributions cleanup
+1 -2
View File
@@ -91,7 +91,7 @@ class Audits extends Action
$actorUserEmail = $impersonatorUserId
? $user->getAttribute('impersonatorUserEmail', '')
: $user->getAttribute('email', '');
$userType = $user->getAttribute('type', ACTIVITY_TYPE_USER);
$userType = $user->getAttribute('type', ACTOR_TYPE_USER);
// Create event data
$eventData = [
@@ -100,7 +100,6 @@ class Audits extends Action
'resource' => $resource,
'userAgent' => $userAgent,
'ip' => $ip,
'location' => '',
'data' => [
'userId' => $actorUserId,
'userName' => $actorUserName,
+1 -1
View File
@@ -48,7 +48,7 @@ class State
$permissions[] = (new Permission($permission, 'user', $ownerOverride))->toString();
}
} else {
$isAPIKey = $user->isApp($authorization->getRoles());
$isAPIKey = $user->isKey($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
$permissions = Permission::aggregate($permissions, $allowedPermissions);
@@ -17,7 +17,7 @@ class User extends Document
public const ROLE_ADMIN = 'admin';
public const ROLE_DEVELOPER = 'developer';
public const ROLE_OWNER = 'owner';
public const ROLE_APPS = 'apps';
public const ROLE_KEYS = 'keys';
public const ROLE_SYSTEM = 'system';
public function getEmail(): ?string
@@ -39,7 +39,7 @@ class User extends Document
{
$roles = [];
if (!$this->isApp($authorization->getRoles())) {
if (!$this->isKey($authorization->getRoles())) {
if ($this->getId()) {
$roles[] = Role::user($this->getId())->toString();
$roles[] = Role::users()->toString();
@@ -115,15 +115,15 @@ class User extends Document
}
/**
* Is App User?
* Is Key User?
*
* @param array<string> $roles
*
* @return bool
*/
public function isApp(array $roles): bool
public function isKey(array $roles): bool
{
if (in_array(self::ROLE_APPS, $roles)) {
if (in_array(self::ROLE_KEYS, $roles)) {
return true;
}
+1 -1
View File
@@ -240,7 +240,7 @@ class Request extends UtopiaRequest
$forwardedUserAgent = $this->getHeader('x-forwarded-user-agent');
if (!empty($forwardedUserAgent)) {
$roles = $this->authorization->getRoles();
$isAppUser = $this->user?->isApp($roles) ?? false;
$isAppUser = $this->user?->isKey($roles) ?? false;
if ($isAppUser) {
return $forwardedUserAgent;
+1 -1
View File
@@ -591,7 +591,7 @@ class Response extends SwooleResponse
$roles = $this->authorization->getRoles();
$user = $this->user ?? new DBUser();
$isPrivilegedUser = $user->isPrivileged($roles);
$isAppUser = $user->isApp($roles);
$isAppUser = $user->isKey($roles);
if ((!$isPrivilegedUser && !$isAppUser) && !$this->showSensitive) {
$data->setAttribute($key, '');
@@ -182,6 +182,20 @@ class Func extends Model
'default' => false,
'example' => false,
])
->addRule('providerBranches', [
'type' => self::TYPE_STRING,
'description' => 'List of branch name patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all branches.',
'default' => [],
'example' => ['main', 'feat/*'],
'array' => true,
])
->addRule('providerPaths', [
'type' => self::TYPE_STRING,
'description' => 'List of file path patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all file changes.',
'default' => [],
'example' => ['src/**', '!docs/**'],
'array' => true,
])
->addRule('buildSpecification', [
'type' => self::TYPE_STRING,
'description' => 'Machine specification for deployment builds.',
@@ -94,7 +94,7 @@ class Project extends Model
->addRule('smtpPort', [
'type' => self::TYPE_INTEGER,
'description' => 'SMTP server port',
'default' => '',
'default' => 0,
'example' => 25,
])
->addRule('smtpUsername', [
@@ -225,7 +225,7 @@ class Project extends Model
$document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility
$document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? '');
$document->setAttribute('smtpHost', $smtp['host'] ?? '');
$document->setAttribute('smtpPort', $smtp['port'] ?? '');
$document->setAttribute('smtpPort', (int) ($smtp['port'] ?? 0));
$document->setAttribute('smtpUsername', $smtp['username'] ?? '');
$document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value
$document->setAttribute('smtpSecure', $smtp['secure'] ?? '');
@@ -173,6 +173,20 @@ class Site extends Model
'default' => false,
'example' => false,
])
->addRule('providerBranches', [
'type' => self::TYPE_STRING,
'description' => 'List of branch name patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all branches.',
'default' => [],
'example' => ['main', 'feat/*'],
'array' => true,
])
->addRule('providerPaths', [
'type' => self::TYPE_STRING,
'description' => 'List of file path patterns that trigger automatic deployments. Supports glob wildcards. Empty list deploys on all file changes.',
'default' => [],
'example' => ['src/**', '!docs/**'],
'array' => true,
])
->addRule('buildSpecification', [
'type' => self::TYPE_STRING,
'description' => 'Machine specification for deployment builds.',
+1 -1
View File
@@ -101,7 +101,7 @@ class HTTPTest extends Scope
$body = $response['body'];
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsString($body['server']);
$this->assertIsString($body['client-web']);
$this->assertIsString($body['server-web']);
$this->assertIsString($body['client-flutter']);
$this->assertIsString($body['console-web']);
$this->assertIsString($body['server-nodejs']);
@@ -1026,123 +1026,101 @@ class AccountCustomClientTest extends Scope
// Use fresh account for predictable log count
$data = $this->createFreshAccountWithSession();
$session = $data['session'];
$headers = array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]);
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]));
$this->assertEventually(function () use ($headers) {
$response = $this->client->call(Client::METHOD_GET, '/account/logs', $headers);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsArray($response['body']['logs']);
$this->assertNotEmpty($response['body']['logs']);
// Fresh account: session.create is always logged. user.create audit may or may not
// be present depending on async audit processing timing.
$logCount = count($response['body']['logs']);
$this->assertContains($logCount, [1, 2]);
$this->assertIsNumeric($response['body']['total']);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsArray($response['body']['logs']);
$this->assertNotEmpty($response['body']['logs']);
$logCount = count($response['body']['logs']);
$this->assertContains($logCount, [1, 2]);
$this->assertIsNumeric($response['body']['total']);
// Check session.create log (logs[0] - most recent)
$this->assertEquals('Windows', $response['body']['logs'][0]['osName']);
$this->assertEquals('WIN', $response['body']['logs'][0]['osCode']);
$this->assertEquals('10', $response['body']['logs'][0]['osVersion']);
$this->assertEquals('session.create', $response['body']['logs'][0]['event']);
$this->assertEquals('Windows', $response['body']['logs'][0]['osName']);
$this->assertEquals('WIN', $response['body']['logs'][0]['osCode']);
$this->assertEquals('10', $response['body']['logs'][0]['osVersion']);
$this->assertEquals('browser', $response['body']['logs'][0]['clientType']);
$this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']);
$this->assertEquals('CH', $response['body']['logs'][0]['clientCode']);
$this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']);
$this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']);
$this->assertEquals('browser', $response['body']['logs'][0]['clientType']);
$this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']);
$this->assertEquals('CH', $response['body']['logs'][0]['clientCode']);
$this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']);
$this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']);
$this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']);
$this->assertEquals('', $response['body']['logs'][0]['deviceBrand']);
$this->assertEquals('', $response['body']['logs'][0]['deviceModel']);
$this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']);
$this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']);
$this->assertEquals('', $response['body']['logs'][0]['deviceBrand']);
$this->assertEquals('', $response['body']['logs'][0]['deviceModel']);
$this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']);
$this->assertEquals('--', $response['body']['logs'][0]['countryCode']);
$this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']);
$this->assertEquals('--', $response['body']['logs'][0]['countryCode']);
$this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']);
if ($logCount === 2) {
// Check user.create log (logs[1] - oldest)
$this->assertEquals('user.create', $response['body']['logs'][1]['event']);
$this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']);
$this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time']));
}
if ($logCount === 2) {
$this->assertEquals('user.create', $response['body']['logs'][1]['event']);
$this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']);
$this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time']));
}
$responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]), [
'queries' => [
Query::limit(1)->toString()
]
]);
$responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
'queries' => [
Query::limit(1)->toString()
]
]);
$this->assertEquals(200, $responseLimit['headers']['status-code']);
$this->assertIsArray($responseLimit['body']['logs']);
$this->assertNotEmpty($responseLimit['body']['logs']);
$this->assertCount(1, $responseLimit['body']['logs']);
$this->assertIsNumeric($responseLimit['body']['total']);
$this->assertEquals(200, $responseLimit['headers']['status-code']);
$this->assertIsArray($responseLimit['body']['logs']);
$this->assertNotEmpty($responseLimit['body']['logs']);
$this->assertCount(1, $responseLimit['body']['logs']);
$this->assertIsNumeric($responseLimit['body']['total']);
$this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]);
$this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]);
$responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]), [
'queries' => [
Query::offset(1)->toString()
]
]);
$responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
'queries' => [
Query::offset(1)->toString()
]
]);
$this->assertEquals($responseOffset['headers']['status-code'], 200);
$this->assertIsArray($responseOffset['body']['logs']);
// With offset(1), remaining logs = logCount - 1
$this->assertCount($logCount - 1, $responseOffset['body']['logs']);
$this->assertIsNumeric($responseOffset['body']['total']);
$this->assertEquals(200, $responseOffset['headers']['status-code']);
$this->assertIsArray($responseOffset['body']['logs']);
$this->assertCount($logCount - 1, $responseOffset['body']['logs']);
$this->assertIsNumeric($responseOffset['body']['total']);
if ($logCount === 2) {
$this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]);
}
if ($logCount === 2) {
$this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]);
}
$responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]), [
'queries' => [
Query::offset(1)->toString(),
Query::limit(1)->toString()
]
]);
$responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
'queries' => [
Query::offset(1)->toString(),
Query::limit(1)->toString()
]
]);
$this->assertEquals(200, $responseLimitOffset['headers']['status-code']);
$this->assertIsArray($responseLimitOffset['body']['logs']);
// With offset(1)+limit(1), remaining logs = min(1, logCount - 1)
$this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']);
$this->assertIsNumeric($responseLimitOffset['body']['total']);
$this->assertEquals(200, $responseLimitOffset['headers']['status-code']);
$this->assertIsArray($responseLimitOffset['body']['logs']);
$this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']);
$this->assertIsNumeric($responseLimitOffset['body']['total']);
if ($logCount === 2) {
$this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]);
}
if ($logCount === 2) {
$this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]);
}
});
/**
* Test for total=false
*/
$logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]), [
$logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [
'total' => false
]);
@@ -2778,17 +2778,17 @@ class FunctionsCustomServerTest extends Scope
$this->assertEmpty($executions['body']['executions'][0]['logs']);
$this->assertEmpty($executions['body']['executions'][0]['errors']);
// Ensure executions count
$executions = $this->listExecutions($functionId);
$this->assertEventually(function () use ($functionId) {
$executions = $this->listExecutions($functionId);
$this->assertEquals(200, $executions['headers']['status-code']);
$this->assertCount(3, $executions['body']['executions']);
$this->assertEquals(200, $executions['headers']['status-code']);
$this->assertCount(3, $executions['body']['executions']);
// Double check logs and errors are empty
foreach ($executions['body']['executions'] as $execution) {
$this->assertEmpty($execution['logs']);
$this->assertEmpty($execution['errors']);
}
foreach ($executions['body']['executions'] as $execution) {
$this->assertEmpty($execution['logs']);
$this->assertEmpty($execution['errors']);
}
}, 10000, 500);
$this->cleanupFunction($functionId);
}
@@ -0,0 +1,484 @@
<?php
namespace Tests\E2E\Services\Organization;
use Appwrite\Extend\Exception;
use Tests\E2E\Client;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\System\System;
trait ProjectsBase
{
private static array $cachedOrganization = [];
private static array $cachedProjectData = [];
/**
* Setup and cache an organization (team) for organization endpoint tests.
*/
protected function setupOrganization(): array
{
if (!empty(self::$cachedOrganization)) {
return self::$cachedOrganization;
}
$teamId = ID::unique();
$team = null;
for ($i = 0; $i < 3; $i++) {
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'teamId' => $teamId,
'name' => 'Organization Test',
]);
if (\in_array($team['headers']['status-code'], [201, 409])) {
break;
}
\usleep(500000);
}
$this->assertContains($team['headers']['status-code'], [201, 409], 'Setup organization (team) failed');
self::$cachedOrganization = [
'teamId' => $team['body']['$id'] ?? $teamId,
];
return self::$cachedOrganization;
}
protected function getOrganizationHeaders(): array
{
$organization = $this->setupOrganization();
return array_merge($this->getHeaders(), [
'x-appwrite-organization' => $organization['teamId'],
]);
}
/**
* Setup and cache a project created via organization endpoints.
*/
protected function setupOrganizationProject(): array
{
if (!empty(self::$cachedProjectData)) {
return self::$cachedProjectData;
}
$project = null;
for ($i = 0; $i < 3; $i++) {
$project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'projectId' => ID::unique(),
'name' => 'Organization Project Test',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
if ($project['headers']['status-code'] === 201) {
break;
}
\usleep(500000);
}
$this->assertEquals(201, $project['headers']['status-code'], 'Setup organization project failed');
self::$cachedProjectData = [
'projectId' => $project['body']['$id'],
'teamId' => $this->setupOrganization()['teamId'],
];
return self::$cachedProjectData;
}
public function testCreateProject(): void
{
$organization = $this->setupOrganization();
$teamId = $organization['teamId'];
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'projectId' => ID::unique(),
'name' => 'Organization Project Test',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertEquals('Organization Project Test', $response['body']['name']);
$this->assertEquals($teamId, $response['body']['teamId']);
$this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']);
$this->assertArrayHasKey('platforms', $response['body']);
$this->assertArrayHasKey('webhooks', $response['body']);
$this->assertArrayHasKey('keys', $response['body']);
/**
* Test for FAILURE - missing organization header
*/
$response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Organization Project Test',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(404, $response['headers']['status-code']);
/**
* Test for FAILURE - empty name
*/
$response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'projectId' => ID::unique(),
'name' => '',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
public function testCreateDuplicateProject(): void
{
$organization = $this->setupOrganization();
$projectId = ID::unique();
$response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'projectId' => $projectId,
'name' => 'Original Organization Project',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(201, $response['headers']['status-code']);
/**
* Test for FAILURE - duplicate project ID
*/
$response = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'projectId' => $projectId,
'name' => 'Duplicate Organization Project',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(409, $response['headers']['status-code']);
$this->assertEquals(409, $response['body']['code']);
$this->assertEquals(Exception::PROJECT_ALREADY_EXISTS, $response['body']['type']);
}
public function testGetProject(): void
{
$data = $this->setupOrganizationProject();
$projectId = $data['projectId'];
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertEquals($projectId, $response['body']['$id']);
$this->assertEquals('Organization Project Test', $response['body']['name']);
$this->assertEquals(PROJECT_STATUS_ACTIVE, $response['body']['status']);
$this->assertArrayHasKey('platforms', $response['body']);
$this->assertArrayHasKey('webhooks', $response['body']);
$this->assertArrayHasKey('keys', $response['body']);
/**
* Test for FAILURE - project not found
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . ID::unique(), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
/**
* Test for FAILURE - project from different organization
*/
$otherTeam = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'teamId' => ID::unique(),
'name' => 'Other Organization',
]);
$this->assertContains($otherTeam['headers']['status-code'], [201, 409]);
$otherTeamId = $otherTeam['body']['$id'] ?? $otherTeam['body']['teamId'];
$otherProject = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], array_merge($this->getHeaders(), [
'x-appwrite-organization' => $otherTeamId,
])), [
'projectId' => ID::unique(),
'name' => 'Other Organization Project',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(201, $otherProject['headers']['status-code']);
$otherProjectId = $otherProject['body']['$id'];
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $otherProjectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
}
public function testUpdateProject(): void
{
$data = $this->setupOrganizationProject();
$projectId = $data['projectId'];
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'name' => 'Updated Organization Project',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals($projectId, $response['body']['$id']);
$this->assertEquals('Updated Organization Project', $response['body']['name']);
/**
* Test for FAILURE - project not found
*/
$response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . ID::unique(), array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'name' => 'Should Fail',
]);
$this->assertEquals(404, $response['headers']['status-code']);
/**
* Test for FAILURE - empty name
*/
$response = $this->client->call(Client::METHOD_PATCH, '/v1/organization/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'name' => '',
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
public function testDeleteProject(): void
{
$organization = $this->setupOrganization();
$project = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project To Delete',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(201, $project['headers']['status-code']);
$projectId = $project['body']['$id'];
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(204, $response['headers']['status-code']);
// Verify project is actually deleted
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
/**
* Test for FAILURE - project not found (already deleted)
*/
$response = $this->client->call(Client::METHOD_DELETE, '/v1/organization/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
}
public function testListProjects(): void
{
$organization = $this->setupOrganization();
$teamId = $organization['teamId'];
// Create a second project in the same organization
$project2 = $this->client->call(Client::METHOD_POST, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'projectId' => ID::unique(),
'name' => 'Second Organization Project',
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(201, $project2['headers']['status-code']);
$project2Id = $project2['body']['$id'];
/**
* Test for SUCCESS - basic list
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
$this->assertGreaterThan(0, count($response['body']['projects']));
$this->assertGreaterThan(0, $response['body']['total']);
/**
* Test search queries
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders(), [
'search' => 'Second Organization Project',
]));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertGreaterThan(0, $response['body']['total']);
$this->assertIsArray($response['body']['projects']);
$this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']);
/**
* Test pagination with limit
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'queries' => [
Query::limit(1)->toString(),
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertCount(1, $response['body']['projects']);
/**
* Test pagination with offset
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'queries' => [
Query::offset(1)->toString(),
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
/**
* Test query by name
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'queries' => [
Query::equal('name', ['Second Organization Project'])->toString(),
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, count($response['body']['projects']));
$this->assertEquals('Second Organization Project', $response['body']['projects'][0]['name']);
/**
* Test cursor pagination
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['projects']);
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(),
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
/**
* Test for FAILURE - invalid cursor
*/
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(),
],
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
public function testListProjectsQuerySelect(): void
{
$data = $this->setupOrganizationProject();
$projectId = $data['projectId'];
$response = $this->client->call(Client::METHOD_GET, '/v1/organization/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getOrganizationHeaders()), [
'queries' => [
Query::select(['name'])->toString(),
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['projects']);
$this->assertEquals('Organization Project Test', $response['body']['projects'][0]['name']);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Organization;
use Tests\E2E\Scopes\ProjectConsole;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class ProjectsConsoleClientTest extends Scope
{
use ProjectsBase;
use ProjectConsole;
use SideConsole;
}
@@ -103,7 +103,7 @@ class ProjectConsoleClientTest extends Scope
$this->assertSame('', $response['body']['smtpReplyToEmail']);
$this->assertSame('', $response['body']['smtpReplyToName']);
$this->assertSame('', $response['body']['smtpHost']);
$this->assertSame('', $response['body']['smtpPort']);
$this->assertSame(0, $response['body']['smtpPort']);
$this->assertSame('', $response['body']['smtpUsername']);
$this->assertSame('', $response['body']['smtpPassword']);
$this->assertSame('', $response['body']['smtpSecure']);
@@ -1802,7 +1802,13 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals('en-us', $response['body']['locale']);
/** Update Email template, fail due to SMTP disabled */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([
$projectWithoutSmtp = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Without SMTP',
'region' => System::getEnv('_APP_REGION', 'default')
]);
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectWithoutSmtp . '/templates/email/verification/en-us', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.1',

Some files were not shown because too many files have changed in this diff Show More