Merge branch '1.8.x' into fix/cli-static-setresource

This commit is contained in:
Chirag Aggarwal
2026-02-11 12:10:35 +05:30
committed by GitHub
29 changed files with 958 additions and 150 deletions
+1
View File
@@ -81,6 +81,7 @@ RUN chmod +x /usr/local/bin/doctor && \
chmod +x /usr/local/bin/worker-certificates && \
chmod +x /usr/local/bin/worker-databases && \
chmod +x /usr/local/bin/worker-deletes && \
chmod +x /usr/local/bin/worker-executions && \
chmod +x /usr/local/bin/worker-functions && \
chmod +x /usr/local/bin/worker-mails && \
chmod +x /usr/local/bin/worker-messaging && \
+5
View File
@@ -1139,6 +1139,11 @@ return [
'description' => 'Key with the requested ID could not be found.',
'code' => 404,
],
Exception::KEY_ALREADY_EXISTS => [
'name' => Exception::KEY_ALREADY_EXISTS,
'description' => 'Key with the same ID already exists. Try again with a different ID.',
'code' => 409,
],
Exception::PLATFORM_NOT_FOUND => [
'name' => Exception::PLATFORM_NOT_FOUND,
'description' => 'Platform with the requested ID could not be found.',
+25 -1
View File
@@ -1469,13 +1469,14 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
->inject('devKey')
->inject('user')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('geodb')
->inject('queueForEvents')
->inject('store')
->inject('proofForPassword')
->inject('proofForToken')
->inject('authorization')
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) {
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) {
$protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https';
$port = $request->getPort();
$callbackBase = $protocol . '://' . $request->getHostname();
@@ -1512,6 +1513,29 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$state = $defaultState;
}
// Allow redirect to rule URL if related to project
// Check if $redirectValidator is instance of Redirect class
if ($redirectValidator instanceof Redirect) {
$domains = \array_filter([
parse_url($state['success'], PHP_URL_HOST) ?? '',
parse_url($state['failure'], PHP_URL_HOST) ?? ''
], fn ($domain) => \is_string($domain) && $domain !== '');
if (!empty($domains)) {
$rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [
Query::equal('domain', \array_values(\array_unique($domains))),
Query::equal('projectInternalId', [$project->getSequence()]),
Query::limit(2)
]));
foreach ($rules as $rule) {
$allowedHostnames = $redirectValidator->getAllowedHostnames();
$allowedHostnames[] = $rule->getAttribute('domain', '');
$redirectValidator->setAllowedHostnames($allowedHostnames);
}
}
}
if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) {
throw new Exception(Exception::PROJECT_INVALID_SUCCESS_URL);
}
+54 -10
View File
@@ -14,16 +14,21 @@ use Appwrite\SDK\Deprecated;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Template\Template;
use Appwrite\Utopia\Database\Validator\CustomId;
use Appwrite\Utopia\Database\Validator\Queries\Keys;
use Appwrite\Utopia\Response;
use PHPMailer\PHPMailer\PHPMailer;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Duplicate;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Domains\Validator\PublicDomain;
use Utopia\Http\Http;
@@ -1094,12 +1099,15 @@ Http::post('/v1/projects/:projectId/keys')
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
// TODO: When migrating to Platform API, mark keyId required for consistency
->param('keyId', 'unique()', new CustomId(), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true)
->param('name', null, new Text(128), 'Key name. Max length: 128 chars.')
->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true)
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) {
$keyId = $keyId == 'unique()' ? ID::unique() : $keyId;
$project = $dbForPlatform->getDocument('projects', $projectId);
@@ -1108,7 +1116,7 @@ Http::post('/v1/projects/:projectId/keys')
}
$key = new Document([
'$id' => ID::unique(),
'$id' => $keyId,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
@@ -1125,7 +1133,11 @@ Http::post('/v1/projects/:projectId/keys')
'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)),
]);
$key = $dbForPlatform->createDocument('keys', $key);
try {
$key = $dbForPlatform->createDocument('keys', $key);
} catch (Duplicate) {
throw new Exception(Exception::KEY_ALREADY_EXISTS);
}
$dbForPlatform->purgeCachedDocument('projects', $project->getId());
@@ -1152,10 +1164,11 @@ Http::get('/v1/projects/:projectId/keys')
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) {
->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
@@ -1163,15 +1176,46 @@ Http::get('/v1/projects/:projectId/keys')
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$keys = $dbForPlatform->find('keys', [
Query::equal('resourceType', ['projects']),
Query::equal('resourceInternalId', [$project->getSequence()]),
Query::limit(5000),
]);
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
// Backwards compatibility
if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) {
$queries[] = Query::limit(5000);
}
$queries[] = Query::equal('resourceType', ['projects']);
$queries[] = Query::equal('resourceInternalId', [$project->getSequence()]);
$cursor = Query::getCursorQueries($queries, false);
$cursor = \reset($cursor);
if ($cursor !== false) {
$validator = new Cursor();
if (!$validator->isValid($cursor)) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
}
$keyId = $cursor->getValue();
$cursorDocument = $dbForPlatform->getDocument('keys', $keyId);
if ($cursorDocument->isEmpty()) {
throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found.");
}
$cursor->setValue($cursorDocument);
}
$filterQueries = Query::groupByType($queries)['filters'];
$keys = $dbForPlatform->find('keys', $queries);
$response->dynamic(new Document([
'keys' => $keys,
'total' => $includeTotal ? count($keys) : 0,
'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0,
]), Response::MODEL_KEY_LIST);
});
+16 -19
View File
@@ -8,7 +8,7 @@ use Appwrite\Auth\Key;
use Appwrite\Event\Certificate;
use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
use Appwrite\Event\Execution;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Network\Cors;
@@ -60,7 +60,7 @@ Config::setParam('domainVerification', false);
Config::setParam('cookieDomain', 'localhost');
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
{
$host = $request->getHostname() ?? '';
if (!empty($previewHostname)) {
@@ -696,14 +696,11 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
throw $th;
}
} finally {
if ($type === 'function') {
$queueForFunctions
->setType(Func::TYPE_ASYNC_WRITE)
if ($type === 'function' || $type === 'site') {
$queueForExecutions
->setExecution($execution)
->setProject($project)
->trigger();
} elseif ($type === 'site') { // TODO: Move it to logs worker later
$dbForProject->createDocument('executions', $execution);
}
}
@@ -877,7 +874,7 @@ Http::init()
->inject('geodb')
->inject('queueForStatsUsage')
->inject('queueForEvents')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('platform')
->inject('isResourceBlocked')
@@ -888,7 +885,7 @@ Http::init()
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Func $queueForFunctions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Execution $queueForExecutions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
@@ -896,7 +893,7 @@ Http::init()
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1175,7 +1172,7 @@ Http::options()
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1188,14 +1185,14 @@ Http::options()
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1571,7 +1568,7 @@ Http::get('/robots.txt')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1581,13 +1578,13 @@ Http::get('/robots.txt')
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1606,7 +1603,7 @@ Http::get('/humans.txt')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1616,13 +1613,13 @@ Http::get('/humans.txt')
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
+2
View File
@@ -5,6 +5,8 @@ use Appwrite\Platform\Modules\Compute\Specification;
const APP_NAME = 'Appwrite';
const APP_DOMAIN = 'appwrite.io';
const APP_VIEWS_DIR = __DIR__ . '/../views';
// Email
const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address
const APP_EMAIL_SECURITY = ''; // Default security email address
+4
View File
@@ -10,6 +10,7 @@ use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Execution;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
@@ -159,6 +160,9 @@ Http::setResource('queueForAudits', function (Publisher $publisher) {
Http::setResource('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
Http::setResource('queueForExecutions', function (Publisher $publisher) {
return new Execution($publisher);
}, ['publisher']);
Http::setResource('eventProcessor', function () {
return new EventProcessor();
}, []);
+5
View File
@@ -9,6 +9,7 @@ use Appwrite\Event\Certificate;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Execution;
use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
@@ -358,6 +359,10 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) {
return new Func($publisher);
}, ['publisher']);
Server::setResource('queueForExecutions', function (Publisher $publisher) {
return new Execution($publisher);
}, ['publisher']);
Server::setResource('queueForRealtime', function () {
return new Realtime();
}, []);
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
exec php /usr/src/code/app/worker.php executions "$@"
+31
View File
@@ -641,6 +641,37 @@ services:
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
appwrite-worker-executions:
entrypoint: worker-executions
<<: *x-logging
container_name: appwrite-worker-executions
image: appwrite-dev
networks:
- appwrite
volumes:
- ./app:/usr/src/code/app
- ./src:/usr/src/code/src
depends_on:
redis:
condition: service_started
mariadb:
condition: service_started
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
- _APP_REDIS_PASS
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_LOGGING_CONFIG
- _APP_DATABASE_SHARED_TABLES
appwrite-worker-functions:
entrypoint: worker-functions
<<: *x-logging
+3
View File
@@ -46,6 +46,9 @@ class Event
public const MESSAGING_QUEUE_NAME = 'v1-messaging';
public const MESSAGING_CLASS_NAME = 'MessagingV1';
public const EXECUTIONS_QUEUE_NAME = 'v1-executions';
public const EXECUTIONS_CLASS_NAME = 'ExecutionsV1';
public const MIGRATIONS_QUEUE_NAME = 'v1-migrations';
public const MIGRATIONS_CLASS_NAME = 'MigrationsV1';
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Appwrite\Event;
use Utopia\Database\Document;
use Utopia\Queue\Publisher;
class Execution extends Event
{
protected ?Document $execution = null;
public function __construct(protected Publisher $publisher)
{
parent::__construct($publisher);
$this
->setQueue(Event::EXECUTIONS_QUEUE_NAME)
->setClass(Event::EXECUTIONS_CLASS_NAME);
}
/**
* Sets execution document for the execution event.
*
* @param Document $execution
* @return self
*/
public function setExecution(Document $execution): self
{
$this->execution = $execution;
return $this;
}
/**
* Returns set execution document for the execution event.
*
* @return null|Document
*/
public function getExecution(): ?Document
{
return $this->execution;
}
/**
* Prepare payload for the execution event.
*
* @return array
*/
protected function preparePayload(): array
{
return [
'project' => $this->project,
'execution' => $this->execution,
];
}
}
-2
View File
@@ -9,8 +9,6 @@ use Utopia\System\System;
class Func extends Event
{
public const TYPE_ASYNC_WRITE = 'async_write';
protected string $jwt = '';
protected string $type = '';
protected string $body = '';
+7
View File
@@ -86,4 +86,11 @@ class StatsUsage extends Event
}),
];
}
public function reset(): Event
{
$this->metrics = [];
parent::reset();
return $this;
}
}
+1
View File
@@ -318,6 +318,7 @@ class Exception extends \Exception
/** Keys */
public const string KEY_NOT_FOUND = 'key_not_found';
public const string KEY_ALREADY_EXISTS = 'key_already_exists';
/** Variables */
public const string VARIABLE_NOT_FOUND = 'variable_not_found';
+21
View File
@@ -22,6 +22,27 @@ class Origin extends Validator
{
}
public function setAllowedHostnames(array $allowedHostnames): self
{
$this->allowedHostnames = $allowedHostnames;
return $this;
}
public function setAllowedSchemes(array $allowedSchemes): self
{
$this->allowedSchemes = $allowedSchemes;
return $this;
}
public function getAllowedHostnames(): array
{
return $this->allowedHostnames;
}
public function getAllowedSchemes(): array
{
return $this->allowedSchemes;
}
/**
* Check if Origin is valid.
@@ -31,7 +31,7 @@ class Get extends Action
->desc('Create GitHub app installation')
->groups(['api', 'vcs'])
->label('scope', 'vcs.read')
->label('error', __DIR__ . '/../../views/general/error.phtml')
->label('error', APP_VIEWS_DIR . '/general/error.phtml')
->label('sdk', new Method(
namespace: 'vcs',
group: 'installations',
@@ -35,7 +35,7 @@ class Get extends Action
->desc('Get installation and authorization from GitHub app')
->groups(['api', 'vcs'])
->label('scope', 'public')
->label('error', __DIR__ . '/../../views/general/error.phtml')
->label('error', APP_VIEWS_DIR . '/general/error.phtml')
->param('installation_id', '', new Text(256, 0), 'GitHub installation ID', true)
->param('setup_action', '', new Text(256, 0), 'GitHub setup action type', true)
->param('state', '', new Text(2048), 'GitHub state. Contains info sent when starting authorization flow.', true)
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Services;
use Appwrite\Platform\Workers\Audits;
use Appwrite\Platform\Workers\Certificates;
use Appwrite\Platform\Workers\Deletes;
use Appwrite\Platform\Workers\Executions;
use Appwrite\Platform\Workers\Functions;
use Appwrite\Platform\Workers\Mails;
use Appwrite\Platform\Workers\Messaging;
@@ -23,6 +24,7 @@ class Workers extends Service
->addAction(Audits::getName(), new Audits())
->addAction(Certificates::getName(), new Certificates())
->addAction(Deletes::getName(), new Deletes())
->addAction(Executions::getName(), new Executions())
->addAction(Functions::getName(), new Functions())
->addAction(Mails::getName(), new Mails())
->addAction(Messaging::getName(), new Messaging())
@@ -0,0 +1,52 @@
<?php
namespace Appwrite\Platform\Workers;
use Exception;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\System\System;
class Executions extends Action
{
public static function getName(): string
{
return 'executions';
}
/**
* @throws Exception
*/
public function __construct()
{
$this
->desc('Executions worker')
->groups(['executions'])
->inject('message')
->inject('dbForProject')
->callback($this->action(...));
}
public function action(
Message $message,
Database $dbForProject,
): void {
$payload = $message->getPayload() ?? [];
if (empty($payload)) {
throw new Exception('Missing payload');
}
$execution = new Document($payload['execution'] ?? []);
if ($execution->isEmpty()) {
throw new Exception('Missing execution');
}
if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check
$dbForProject->upsertDocument('executions', $execution);
}
}
}
+48 -82
View File
@@ -4,6 +4,7 @@ namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Event\Event;
use Appwrite\Event\Execution as ExecutionEvent;
use Appwrite\Event\Func;
use Appwrite\Event\Realtime;
use Appwrite\Event\StatsUsage;
@@ -16,8 +17,6 @@ use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Conflict;
use Utopia\Database\Exception\Structure;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
@@ -50,6 +49,7 @@ class Functions extends Action
->inject('queueForRealtime')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForExecutions')
->inject('log')
->inject('executor')
->inject('isResourceBlocked')
@@ -65,6 +65,7 @@ class Functions extends Action
Realtime $queueForRealtime,
Event $queueForEvents,
StatsUsage $queueForStatsUsage,
ExecutionEvent $queueForExecutions,
Log $log,
Executor $executor,
callable $isResourceBlocked
@@ -77,15 +78,6 @@ class Functions extends Action
$type = $payload['type'] ?? '';
// Short-term solution to offhand write operation from API container
if ($type === Func::TYPE_ASYNC_WRITE) {
$execution = new Document($payload['execution'] ?? []);
if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check
$dbForProject->createDocument('executions', $execution);
}
return;
}
$events = $payload['events'] ?? [];
$data = $payload['body'] ?? '';
$eventData = $payload['payload'] ?? '';
@@ -166,6 +158,7 @@ class Functions extends Action
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -210,6 +203,7 @@ class Functions extends Action
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -236,6 +230,7 @@ class Functions extends Action
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -268,7 +263,8 @@ class Functions extends Action
*/
private function fail(
string $message,
Database $dbForProject,
Document $project,
ExecutionEvent $queueForExecutions,
Document $function,
string $trigger,
string $path,
@@ -311,13 +307,10 @@ class Functions extends Action
'duration' => 0.0,
]);
if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check
$execution = $dbForProject->createDocument('executions', $execution);
if ($execution->isEmpty()) {
throw new Exception('Failed to create execution');
}
}
$queueForExecutions
->setExecution($execution)
->setProject($project)
->trigger();
}
/**
@@ -341,9 +334,6 @@ class Functions extends Action
* @param string|null $eventData
* @param string|null $executionId
* @return void
* @throws Structure
* @throws \Utopia\Database\Exception
* @throws Conflict
*/
private function execute(
Log $log,
@@ -353,6 +343,7 @@ class Functions extends Action
Realtime $queueForRealtime,
StatsUsage $queueForStatsUsage,
Event $queueForEvents,
ExecutionEvent $queueForExecutions,
Document $project,
Document $function,
Executor $executor,
@@ -380,19 +371,19 @@ class Functions extends Action
if ($deployment->getAttribute('resourceId') !== $functionId) {
$errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.';
$this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event);
$this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event);
return;
}
if ($deployment->isEmpty()) {
$errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.';
$this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event);
$this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event);
return;
}
if ($deployment->getAttribute('status') !== 'ready') {
$errorMessage = 'The execution could not be completed because the build is not ready. Please wait for the build to complete and try again.';
$this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event);
$this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event);
return;
}
@@ -423,60 +414,38 @@ class Functions extends Action
$headers['x-appwrite-continent-code'] = '';
$headers['x-appwrite-continent-eu'] = 'false';
/** Create execution or update execution status */
$execution = $dbForProject->getDocument('executions', $executionId ?? '');
if ($execution->isEmpty()) {
/** Create or update execution to processing status */
if (empty($executionId)) {
$executionId = ID::unique();
$headers['x-appwrite-execution-id'] = $executionId;
$headersFiltered = [];
foreach ($headers as $key => $value) {
if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) {
$headersFiltered[] = [ 'name' => $key, 'value' => $value ];
}
}
}
$headers['x-appwrite-execution-id'] = $executionId;
$execution = new Document([
'$id' => $executionId,
'$permissions' => $user->isEmpty() ? [] : [Permission::read(Role::user($user->getId()))],
'resourceInternalId' => $function->getSequence(),
'resourceId' => $function->getId(),
'resourceType' => 'functions',
'deploymentInternalId' => $deployment->getSequence(),
'deploymentId' => $deployment->getId(),
'trigger' => $trigger,
'status' => 'processing',
'responseStatusCode' => 0,
'responseHeaders' => [],
'requestPath' => $path,
'requestMethod' => $method,
'requestHeaders' => $headersFiltered,
'errors' => '',
'logs' => '',
'duration' => 0.0,
]);
if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check
$execution = $dbForProject->createDocument('executions', $execution);
// TODO: @Meldiron Trigger executions.create event here
if ($execution->isEmpty()) {
throw new Exception('Failed to create or read execution');
}
$headersFiltered = [];
foreach ($headers as $key => $value) {
if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) {
$headersFiltered[] = [ 'name' => $key, 'value' => $value ];
}
}
if ($execution->getAttribute('status') !== 'processing') {
$execution->setAttribute('status', 'processing');
if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check
try {
$execution = $dbForProject->updateDocument('executions', $executionId, $execution);
} catch (\Throwable $e) {
$log->addExtra('updateError', $e->getMessage());
}
}
}
$execution = new Document([
'$id' => $executionId,
'$permissions' => $user->isEmpty() ? [] : [Permission::read(Role::user($user->getId()))],
'resourceInternalId' => $function->getSequence(),
'resourceId' => $function->getId(),
'resourceType' => 'functions',
'deploymentInternalId' => $deployment->getSequence(),
'deploymentId' => $deployment->getId(),
'trigger' => $trigger,
'status' => 'processing',
'responseStatusCode' => 0,
'responseHeaders' => [],
'requestPath' => $path,
'requestMethod' => $method,
'requestHeaders' => $headersFiltered,
'errors' => '',
'logs' => '',
'duration' => 0.0,
]);
$durationStart = \microtime(true);
@@ -618,14 +587,11 @@ class Functions extends Action
$error = $th->getMessage();
$errorCode = $th->getCode();
} finally {
/** Update execution status */
if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check
try {
$execution = $dbForProject->updateDocument('executions', $executionId, $execution);
} catch (\Throwable $e) {
$log->addExtra('updateError', $e->getMessage());
}
}
/** Persist final execution status */
$queueForExecutions
->setExecution($execution)
->setProject($project)
->trigger();
/** Trigger usage queue */
$queueForStatsUsage
+107 -1
View File
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
use Appwrite\Event\Mail;
use Appwrite\Event\Realtime;
use Appwrite\Event\StatsUsage;
use Appwrite\Extend\Exception;
use Appwrite\Template\Template;
use Utopia\Config\Config;
@@ -25,6 +26,10 @@ use Utopia\Migration\Destination;
use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite;
use Utopia\Migration\Destinations\CSV as DestinationCSV;
use Utopia\Migration\Exception as MigrationException;
use Utopia\Migration\Resource;
use Utopia\Migration\Resources\Database\Database as ResourceDatabase;
use Utopia\Migration\Resources\Database\Row as ResourceRow;
use Utopia\Migration\Resources\Database\Table as ResourceTable;
use Utopia\Migration\Source;
use Utopia\Migration\Sources\Appwrite as SourceAppwrite;
use Utopia\Migration\Sources\CSV;
@@ -52,6 +57,7 @@ class Migrations extends Action
*/
protected array $sourceReport = [];
private string $source;
/**
* @var callable|null
*/
@@ -78,6 +84,7 @@ class Migrations extends Action
->inject('deviceForMigrations')
->inject('deviceForFiles')
->inject('queueForMails')
->inject('queueForStatsUsage')
->inject('plan')
->inject('authorization')
->callback($this->action(...));
@@ -96,6 +103,7 @@ class Migrations extends Action
Device $deviceForMigrations,
Device $deviceForFiles,
Mail $queueForMails,
StatsUsage $queueForStatsUsage,
array $plan,
Authorization $authorization,
): void {
@@ -139,6 +147,7 @@ class Migrations extends Action
$migration,
$queueForRealtime,
$queueForMails,
$queueForStatsUsage,
$platform,
$authorization
);
@@ -324,6 +333,7 @@ class Migrations extends Action
Document $migration,
Realtime $queueForRealtime,
Mail $queueForMails,
StatsUsage $queueForStatsUsage,
array $platform,
Authorization $authorization,
): void {
@@ -368,6 +378,7 @@ class Migrations extends Action
$destination
);
$aggregatedResources = [];
/** Start Transfer */
if (empty($source->getErrors())) {
$migration->setAttribute('stage', 'migrating');
@@ -375,9 +386,40 @@ class Migrations extends Action
$transfer->run(
$migration->getAttribute('resources'),
function () use ($migration, $transfer, $project, $queueForRealtime) {
function ($resources) use ($migration, $transfer, $project, $queueForRealtime, &$aggregatedResources) {
$migration->setAttribute('resourceData', json_encode($transfer->getCache()));
$migration->setAttribute('statusCounters', json_encode($transfer->getStatusCounters()));
if (!empty($resources)) {
/**
* @var Resource $resource
*/
$resource = $resources[0];
$count = count($resources);
$databaseId = null;
$tableId = null;
switch ($resource->getName()) {
case ResourceTable::getName():
/** @var ResourceTable $resource */
$databaseId = $resource->getDatabase()->getSequence();
break;
case ResourceRow::getName():
/** @var ResourceRow $resource */
$table = $resource->getTable();
$databaseId = $table->getDatabase()->getSequence();
$tableId = $table->getSequence();
break;
default:
break;
}
$aggregatedResources[] = [
'name' => $resource->getName(),
'count' => $count,
'databaseId' => $databaseId,
'tableId' => $tableId
];
}
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
},
$migration->getAttribute('resourceId'),
@@ -443,6 +485,16 @@ class Migrations extends Action
}
if ($migration->getAttribute('status', '') === 'completed') {
foreach ($aggregatedResources as $resource) {
$this->processMigrationResourceStats(
$resource,
$queueForStatsUsage,
$project,
$migration->getAttribute('source'),
$authorization,
$migration->getAttribute('resourceId')
);
}
$destination?->success();
$source?->success();
@@ -737,4 +789,58 @@ class Migrations extends Action
return $errors;
}
private function processMigrationResourceStats(array $resources, StatsUsage $queueForStatsUsage, Document $projectDocument, string $source, Authorization $authorization, ?string $resourceId)
{
$resourceName = $resources['name'];
$count = $resources['count'];
$databaseInternalId = $resources['databaseId'];
$tableInternalId = $resources['tableId'];
if ($source === CSV::getName()) {
[$databaseId, $tableId] = explode(':', $resourceId);
$database = $authorization->skip(fn () => $this->dbForProject->getDocument('databases', $databaseId));
$table = $authorization->skip(fn () => $this->dbForProject->getDocument('database_' . $database->getSequence(), $tableId));
$databaseInternalId = (int) $database->getSequence();
$tableInternalId = (int) $table->getSequence();
}
switch ($resourceName) {
case ResourceDatabase::getName():
$queueForStatsUsage->addMetric(METRIC_DATABASES, $count);
break;
case ResourceTable::getName():
$queueForStatsUsage
->addMetric(METRIC_COLLECTIONS, $count)
->addMetric(
str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS),
$count
);
break;
case ResourceRow::getName():
$queueForStatsUsage
->addMetric(
str_replace(
['{databaseInternalId}','{collectionInternalId}'],
[$databaseInternalId, $tableInternalId],
METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS
),
$count
)
->addMetric(
str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS),
$count
)
->addMetric(METRIC_DOCUMENTS, $count);
break;
default:
break;
}
$queueForStatsUsage->setProject($projectDocument)->trigger();
$queueForStatsUsage->reset();
}
}
@@ -0,0 +1,18 @@
<?php
namespace Appwrite\Utopia\Database\Validator\Queries;
class Keys extends Base
{
public const ALLOWED_ATTRIBUTES = [
'expire',
'accessedAt',
'name',
'scopes',
];
public function __construct()
{
parent::__construct('keys', self::ALLOWED_ATTRIBUTES);
}
}
+2
View File
@@ -60,6 +60,7 @@ trait ProjectCustom
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-project' => 'console',
], [
'keyId' => ID::unique(),
'name' => 'Demo Project Key',
'scopes' => [
'users.read',
@@ -194,6 +195,7 @@ trait ProjectCustom
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-project' => 'console',
], [
'keyId' => ID::unique(),
'name' => 'Demo Project Key',
'scopes' => $scopes,
]);
@@ -1751,7 +1751,10 @@ class FunctionsCustomServerTest extends Scope
$this->assertEventually(function () use ($functionId, $userId) {
$executions = $this->listExecutions($functionId);
$lastExecution = $executions['body']['executions'][0];
$this->assertEquals(200, $executions['headers']['status-code']);
$executionsList = $executions['body']['executions'] ?? [];
$this->assertNotEmpty($executionsList);
$lastExecution = $executionsList[0];
$this->assertEquals('completed', $lastExecution['status']);
$this->assertEquals(204, $lastExecution['responseStatusCode']);
@@ -1168,7 +1168,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'duration' => 60, // Set session duration to 1 minute
'duration' => 10, // Set session duration to 10 seconds
]);
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1177,7 +1177,7 @@ class ProjectsConsoleClientTest extends Scope
$this->assertArrayHasKey('platforms', $response['body']);
$this->assertArrayHasKey('webhooks', $response['body']);
$this->assertArrayHasKey('keys', $response['body']);
$this->assertEquals(60, $response['body']['authDuration']);
$this->assertEquals(10, $response['body']['authDuration']);
$projectId = $response['body']['$id'];
@@ -1218,44 +1218,30 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals(200, $response['headers']['status-code']);
// Check session doesn't expire too soon.
sleep(30);
// Eventually session expires, within 15 seconds (10+variance)
$this->assertEventually(function () use ($projectId, $sessionCookie) {
// Get User
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'Cookie' => $sessionCookie,
]));
// Get User
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'Cookie' => $sessionCookie,
]));
$this->assertEquals(401, $response['headers']['status-code']);
}, timeoutMs: 15 * 1000);
$this->assertEquals(200, $response['headers']['status-code']);
// Wait just over a minute
sleep(35);
// Get User
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'Cookie' => $sessionCookie,
]));
$this->assertEquals(401, $response['headers']['status-code']);
// Set session duration to 15s
// Set session duration to 10min
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'duration' => 15, // seconds
'duration' => 600, // seconds
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(15, $response['body']['authDuration']);
// Wait 20 seconds, ensure non-valid session
\sleep(20);
$this->assertEquals(600, $response['body']['authDuration']);
// Ensure session is still expired (new duration only affects new sessions)
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
@@ -2774,6 +2760,7 @@ class ProjectsConsoleClientTest extends Scope
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['functions.read', 'teams.write'],
]);
@@ -3123,6 +3110,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['teams.read', 'teams.write'],
]);
@@ -3138,6 +3126,66 @@ class ProjectsConsoleClientTest extends Scope
$this->assertArrayHasKey('accessedAt', $response['body']);
$this->assertEmpty($response['body']['accessedAt']);
/**
* Test for SUCCESS without key ID
*/
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'name' => 'Key Custom',
'scopes' => ['teams.read', 'teams.write'],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
/**
* Test for SUCCESS with custom ID
*/
$customKeyId = \uniqid() . 'custom-id';
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => $customKeyId,
'name' => 'Key Custom',
'scopes' => ['teams.read', 'teams.write'],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertSame($customKeyId, $response['body']['$id']);
/**
* Test for FAILURE with custom ID
*/
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => $customKeyId,
'name' => 'Key Custom',
'scopes' => ['teams.read', 'teams.write'],
]);
$this->assertEquals(409, $response['headers']['status-code']);
/**
* Test for SUCCESS with magic string ID
*/
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => 'unique()',
'name' => 'Key Custom',
'scopes' => ['teams.read', 'teams.write'],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertNotSame('unique()', $response['body']['$id']);
$data = array_merge($data, [
'keyId' => $response['body']['$id'],
'secret' => $response['body']['secret']
@@ -3150,6 +3198,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['unknown'],
]);
@@ -3167,19 +3216,258 @@ class ProjectsConsoleClientTest extends Scope
{
$id = $data['projectId'] ?? '';
/** Create a second key with an expiry for query testing */
$expireDate = DateTime::addSeconds(new \DateTime(), 3600);
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'name' => 'Key Test 2',
'scopes' => ['users.read'],
'expire' => $expireDate,
]);
$this->assertEquals(201, $response['headers']['status-code']);
$key2Id = $response['body']['$id'];
/** List all keys (no queries) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(5, $response['body']['total']);
$this->assertCount(5, $response['body']['keys']);
/** List keys with limit */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::limit(1)->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertCount(1, $response['body']['keys']);
$this->assertEquals(5, $response['body']['total']);
/** List keys with offset */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::offset(1)->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertCount(4, $response['body']['keys']);
$this->assertEquals(5, $response['body']['total']);
/** List keys with cursor after */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => $data['keyId']]))->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertCount(1, $response['body']['keys']);
$this->assertEquals(5, $response['body']['total']);
$this->assertEquals($key2Id, $response['body']['keys'][0]['$id']);
/** List keys filtering by expire (lessThan now — should match none) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::lessThan('expire', (new \DateTime())->format('Y-m-d H:i:s'))->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(0, $response['body']['total']);
/** List keys filtering by expire (greaterThan now — should match the key with expiry) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::greaterThan('expire', (new \DateTime())->format('Y-m-d H:i:s'))->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(1, $response['body']['total']);
$this->assertCount(1, $response['body']['keys']);
/** List keys filtering by name (equal — exact match) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('name', ['Key Test'])->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(1, $response['body']['total']);
$this->assertCount(1, $response['body']['keys']);
$this->assertEquals('Key Test', $response['body']['keys'][0]['name']);
/** List keys filtering by name (equal — multiple values) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('name', ['Key Test', 'Key Test 2'])->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(2, $response['body']['total']);
$this->assertCount(2, $response['body']['keys']);
/** List keys filtering by name (equal — no match) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('name', ['Non Existent Key'])->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(0, $response['body']['total']);
$this->assertCount(0, $response['body']['keys']);
/** List keys filtering by scopes (contains — match key with teams.read) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::contains('scopes', ['teams.read'])->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(4, $response['body']['total']);
$this->assertCount(4, $response['body']['keys']);
/** List keys filtering by scopes (contains — match key with users.read) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::contains('scopes', ['users.read'])->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(1, $response['body']['total']);
$this->assertCount(1, $response['body']['keys']);
/** List keys filtering by scopes (contains — no match) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::contains('scopes', ['databases.read'])->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(0, $response['body']['total']);
$this->assertCount(0, $response['body']['keys']);
/** List keys filtering by name and scopes combined */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('name', ['Key Test'])->toString(),
Query::contains('scopes', ['teams.read'])->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(1, $response['body']['total']);
$this->assertCount(1, $response['body']['keys']);
$this->assertEquals('Key Test', $response['body']['keys'][0]['name']);
/** List keys with orderDesc */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::orderDesc('$createdAt')->toString(),
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertCount(5, $response['body']['keys']);
$this->assertGreaterThan($response['body']['keys'][1]['$createdAt'], $response['body']['keys'][0]['$createdAt']);
/** List keys with total disabled */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'total' => false,
'queries' => [
Query::limit(1)->toString()
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertCount(1, $response['body']['keys']);
$this->assertEquals(0, $response['body']['total']);
/**
* Test for FAILURE
*/
/** Test invalid query attribute */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::equal('secret', ['test'])->toString(),
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
/** Test invalid cursor */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => 'invalid']))->toString(),
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
return $data;
}
@@ -3200,7 +3488,7 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertEquals($keyId, $response['body']['$id']);
$this->assertEquals('Key Test', $response['body']['name']);
$this->assertEquals('Key Custom', $response['body']['name']);
$this->assertContains('teams.read', $response['body']['scopes']);
$this->assertContains('teams.write', $response['body']['scopes']);
$this->assertCount(2, $response['body']['scopes']);
@@ -3240,6 +3528,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['users.write'],
'expire' => DateTime::addSeconds(new \DateTime(), 3600),
@@ -3260,6 +3549,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['health.read'],
'expire' => null,
@@ -3282,6 +3572,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['health.read'],
'expire' => DateTime::addSeconds(new \DateTime(), -3600),
@@ -3323,6 +3614,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['teams.read'],
'expire' => DateTime::addSeconds(new \DateTime(), 3600),
@@ -3355,6 +3647,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['health.read'],
'expire' => DateTime::addSeconds(new \DateTime(), 3600),
@@ -4364,6 +4657,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['users.read', 'users.write'],
]);
@@ -4384,6 +4678,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Key Test',
'scopes' => ['users.read', 'users.write'],
]);
@@ -5191,6 +5486,24 @@ class ProjectsConsoleClientTest extends Scope
], followRedirects: false);
$this->assertEquals(400, $response['headers']['status-code']);
// Also ensure final step blocks unknown redirect URL
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'origin' => '',
'referer' => 'https://mockserver.com',
], [
'code' => 'any-code',
'state' => \json_encode([
'success' => 'https://domain-without-rule.com',
'failure' => 'https://domain-without-rule.com'
]),
'error' => '',
'error_description' => '',
], followRedirects: false);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertStringContainsString('project_invalid_success_url', $response['body']);
// Ensure rule's domain can be redirect URL
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [
'content-type' => 'application/json',
@@ -5203,6 +5516,24 @@ class ProjectsConsoleClientTest extends Scope
], followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
// Also ensure final step allows redirect URL
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'origin' => '',
'referer' => 'https://mockserver.com',
], [
'code' => 'any-code',
'state' => \json_encode([
'success' => 'https://' . $domain,
'failure' => 'https://' . $domain
]),
'error' => '',
'error_deescription' => '',
], followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringContainsString('https://' . $domain, $response['headers']['location']);
// Ensure unknown domain cannot be redirect URL
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [
'content-type' => 'application/json',
@@ -5230,6 +5561,55 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals(201, $response['headers']['status-code']);
}
public function testOAuthRedirectWithCustomSchemeState(): void
{
// Prepare project
$projectId = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'testOAuthRedirectWithCustomSchemeState',
'region' => System::getEnv('_APP_REGION', 'default')
]);
$provider = 'mock';
$appId = '1';
$secret = '123456';
// Prepare OAuth provider
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/oauth2', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'provider' => $provider,
'appId' => $appId,
'secret' => $secret,
'enabled' => true,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$scheme = 'appwrite-callback-' . $projectId;
$state = \json_encode([
'success' => $scheme . ':///',
'failure' => $scheme . ':///'
]);
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'origin' => '',
'referer' => '',
], [
'code' => 'any-code',
'state' => $state,
'error' => 'access_denied',
'error_description' => 'test',
], followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringStartsWith($scheme . '://', $response['headers']['location']);
$this->assertStringContainsString('error=', $response['headers']['location']);
}
/**
* @group abuseEnabled
*/
+21
View File
@@ -212,6 +212,27 @@ services:
- _APP_DB_USER
- _APP_DB_PASS
appwrite-worker-executions:
entrypoint: worker-executions
container_name: appwrite-worker-executions
build:
context: .
restart: unless-stopped
networks:
- appwrite
depends_on:
- redis
- mariadb
environment:
- _APP_ENV
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
appwrite-worker-functions:
entrypoint: worker-functions
container_name: appwrite-worker-functions
+1 -1
View File
@@ -23,7 +23,7 @@ class ComposeTest extends TestCase
public function testServices(): void
{
$this->assertCount(15, $this->object->getServices());
$this->assertCount(16, $this->object->getServices());
$this->assertEquals('appwrite', $this->object->getService('appwrite')->getContainerName());
$this->assertEquals('', $this->object->getService('appwrite')->getImageVersion());
$this->assertEquals('3.6', $this->object->getService('traefik')->getImageVersion());
@@ -74,4 +74,60 @@ class OriginTest extends TestCase
$this->assertEquals(false, $validator->isValid('random-scheme://localhost'));
$this->assertEquals('Invalid Scheme. The scheme used (random-scheme) in the Origin (random-scheme://localhost) is not supported. If you are using a custom scheme, please change it to `appwrite-callback-<PROJECT_ID>`', $validator->getDescription());
}
public function testGetAllowedHostnames(): void
{
$validator = new Origin(
allowedHostnames: ['appwrite.io', 'localhost'],
allowedSchemes: ['exp']
);
$this->assertEquals(['appwrite.io', 'localhost'], $validator->getAllowedHostnames());
}
public function testGetAllowedSchemes(): void
{
$validator = new Origin(
allowedHostnames: ['appwrite.io'],
allowedSchemes: ['exp', 'appwrite-callback-123']
);
$this->assertEquals(['exp', 'appwrite-callback-123'], $validator->getAllowedSchemes());
}
public function testSetAllowedHostnames(): void
{
$validator = new Origin(
allowedHostnames: ['appwrite.io'],
allowedSchemes: ['exp']
);
$this->assertEquals(true, $validator->isValid('https://appwrite.io'));
$this->assertEquals(false, $validator->isValid('https://example.com'));
$result = $validator->setAllowedHostnames(['example.com']);
$this->assertSame($validator, $result);
$this->assertEquals(['example.com'], $validator->getAllowedHostnames());
$this->assertEquals(true, $validator->isValid('https://example.com'));
$this->assertEquals(false, $validator->isValid('https://appwrite.io'));
}
public function testSetAllowedSchemes(): void
{
$validator = new Origin(
allowedHostnames: ['appwrite.io'],
allowedSchemes: ['exp']
);
$this->assertEquals(true, $validator->isValid('exp://'));
$this->assertEquals(false, $validator->isValid('appwrite-callback-456://'));
$result = $validator->setAllowedSchemes(['appwrite-callback-456']);
$this->assertSame($validator, $result);
$this->assertEquals(['appwrite-callback-456'], $validator->getAllowedSchemes());
$this->assertEquals(true, $validator->isValid('appwrite-callback-456://'));
$this->assertEquals(false, $validator->isValid('exp://'));
}
}