Merge branch 'joins2' of https://github.com/appwrite/appwrite into joins-feature

This commit is contained in:
fogelito
2026-02-11 08:58:40 +02:00
26 changed files with 773 additions and 160 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.',
+17 -13
View File
@@ -1512,21 +1512,25 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
}
// Allow redirect to rule URL if related to project
//Check if $redirectValidator is instance of Redirect class
// Check if $redirectValidator is instance of Redirect class
if ($redirectValidator instanceof Redirect) {
$rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [
Query::equal('domain', [
parse_url($state['success'], PHP_URL_HOST),
parse_url($state['failure'], PHP_URL_HOST)
]),
Query::equal('projectInternalId', [$project->getSequence()]),
Query::limit(2)
]));
$domains = \array_filter([
parse_url($state['success'], PHP_URL_HOST) ?? '',
parse_url($state['failure'], PHP_URL_HOST) ?? ''
], fn ($domain) => \is_string($domain) && $domain !== '');
foreach ($rules as $rule) {
$allowedHostnames = $redirectValidator->getAllowedHostnames();
$allowedHostnames[] = $rule->getAttribute('domain', '');
$redirectValidator->setAllowedHostnames($allowedHostnames);
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);
}
}
}
+48 -8
View File
@@ -15,16 +15,20 @@ 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;
@@ -1129,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());
@@ -1156,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);
@@ -1167,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;
@@ -61,7 +61,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)) {
@@ -697,14 +697,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);
}
}
@@ -878,7 +875,7 @@ Http::init()
->inject('geodb')
->inject('queueForStatsUsage')
->inject('queueForEvents')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('platform')
->inject('isResourceBlocked')
@@ -889,7 +886,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
*/
@@ -897,7 +894,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);
}
}
@@ -1179,7 +1176,7 @@ Http::options()
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1192,14 +1189,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);
}
}
@@ -1575,7 +1572,7 @@ Http::get('/robots.txt')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1585,13 +1582,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);
}
}
@@ -1610,7 +1607,7 @@ Http::get('/humans.txt')
->inject('getProjectDB')
->inject('queueForEvents')
->inject('queueForStatsUsage')
->inject('queueForFunctions')
->inject('queueForExecutions')
->inject('executor')
->inject('geodb')
->inject('isResourceBlocked')
@@ -1620,13 +1617,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
@@ -642,6 +642,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';
@@ -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\CLI\Console;
use Utopia\Config\Config;
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'] ?? '';
@@ -167,6 +159,7 @@ class Functions extends Action
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -211,6 +204,7 @@ class Functions extends Action
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -237,6 +231,7 @@ class Functions extends Action
queueForRealtime: $queueForRealtime,
queueForStatsUsage: $queueForStatsUsage,
queueForEvents: $queueForEvents,
queueForExecutions: $queueForExecutions,
project: $project,
function: $function,
executor: $executor,
@@ -269,7 +264,8 @@ class Functions extends Action
*/
private function fail(
string $message,
Database $dbForProject,
Document $project,
ExecutionEvent $queueForExecutions,
Document $function,
string $trigger,
string $path,
@@ -312,13 +308,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();
}
/**
@@ -342,9 +335,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,
@@ -354,6 +344,7 @@ class Functions extends Action
Realtime $queueForRealtime,
StatsUsage $queueForStatsUsage,
Event $queueForEvents,
ExecutionEvent $queueForExecutions,
Document $project,
Document $function,
Executor $executor,
@@ -381,19 +372,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;
}
@@ -424,60 +415,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);
@@ -619,14 +588,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\CLI\Console;
@@ -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);
}
}
@@ -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']);
@@ -1219,7 +1219,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']);
@@ -1228,7 +1228,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'];
@@ -1269,44 +1269,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,
@@ -3208,7 +3194,7 @@ class ProjectsConsoleClientTest extends Scope
/**
* Test for SUCCESS with custom ID
*/
$customKeyId = 'key-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'],
@@ -3221,6 +3207,20 @@ class ProjectsConsoleClientTest extends Scope
$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
*/
@@ -3267,19 +3267,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;
}
@@ -5373,6 +5612,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());