From dc0a5c88b7390ede6f7a21b0b4ee97cf3f03f40e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 14:28:31 +0530 Subject: [PATCH 1/8] refactor: migrate audits certificates screenshots to publishers --- app/cli.php | 4 - app/controllers/general.php | 17 +-- app/controllers/shared/api.php | 53 ++++--- app/init/resources.php | 15 ++ app/init/resources/request.php | 20 ++- app/init/worker/message.php | 16 +-- src/Appwrite/Event/Context/Audit.php | 134 ++++++++++++++++++ src/Appwrite/Event/Message/Audit.php | 55 +++++++ src/Appwrite/Event/Message/Certificate.php | 43 ++++++ src/Appwrite/Event/Message/Screenshot.php | 37 +++++ src/Appwrite/Event/Publisher/Audit.php | 35 +++++ src/Appwrite/Event/Publisher/Certificate.php | 27 ++++ src/Appwrite/Event/Publisher/Screenshot.php | 27 ++++ .../Modules/Functions/Workers/Builds.php | 19 +-- .../Modules/Functions/Workers/Screenshots.php | 5 +- .../Health/Http/Health/Queue/Audits/Get.php | 8 +- .../Http/Health/Queue/Certificates/Get.php | 8 +- .../Health/Http/Health/Queue/Failed/Get.php | 24 ++-- .../Health/Http/Health/Queue/Logs/Get.php | 8 +- .../Modules/Proxy/Http/Rules/API/Create.php | 17 +-- .../Proxy/Http/Rules/Function/Create.php | 17 +-- .../Proxy/Http/Rules/Redirect/Create.php | 17 +-- .../Modules/Proxy/Http/Rules/Site/Create.php | 17 +-- .../Proxy/Http/Rules/Verification/Update.php | 15 +- src/Appwrite/Platform/Tasks/Interval.php | 34 +++-- src/Appwrite/Platform/Tasks/Maintenance.php | 26 ++-- src/Appwrite/Platform/Tasks/SSL.php | 19 +-- src/Appwrite/Platform/Workers/Audits.php | 35 ++--- .../Platform/Workers/Certificates.php | 41 +++--- 29 files changed, 592 insertions(+), 201 deletions(-) create mode 100644 src/Appwrite/Event/Context/Audit.php create mode 100644 src/Appwrite/Event/Message/Audit.php create mode 100644 src/Appwrite/Event/Message/Certificate.php create mode 100644 src/Appwrite/Event/Message/Screenshot.php create mode 100644 src/Appwrite/Event/Publisher/Audit.php create mode 100644 src/Appwrite/Event/Publisher/Certificate.php create mode 100644 src/Appwrite/Event/Publisher/Screenshot.php diff --git a/app/cli.php b/app/cli.php index 73908510d9..ee0b4a6103 100644 --- a/app/cli.php +++ b/app/cli.php @@ -2,7 +2,6 @@ require_once __DIR__ . '/init.php'; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; @@ -263,9 +262,6 @@ $container->set('queueForFunctions', function (Publisher $publisher) { $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); -$container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); -}, ['publisher']); $container->set('logError', function (Registry $register) { return function (Throwable $error, string $namespace, string $action) use ($register) { Console::error('[Error] Timestamp: ' . date('c', time())); diff --git a/app/controllers/general.php b/app/controllers/general.php index c6e2eacb33..53776e1ccc 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -7,9 +7,9 @@ use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Bus\Events\ExecutionCompleted; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Network\Cors; use Appwrite\Platform\Appwrite; @@ -1006,11 +1006,11 @@ Http::init() ->inject('request') ->inject('console') ->inject('dbForPlatform') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('platform') ->inject('authorization') ->inject('certifiedDomains') - ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) { + ->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates, array $platform, Authorization $authorization, Table $certifiedDomains) { $hostname = $request->getHostname(); $platformHostnames = $platform['hostnames'] ?? []; @@ -1036,7 +1036,7 @@ Http::init() } // 4. Check/create rule (requires DB access) - $authorization->skip(function () use ($dbForPlatform, $domain, $console, $queueForCertificates, $certifiedDomains) { + $authorization->skip(function () use ($dbForPlatform, $domain, $console, $publisherForCertificates, $certifiedDomains) { try { // TODO: (@Meldiron) Remove after 1.7.x migration $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; @@ -1092,10 +1092,11 @@ Http::init() $dbForPlatform->createDocument('rules', $document); Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); - $queueForCertificates - ->setDomain($document) - ->setSkipRenewCheck(true) - ->trigger(); + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $console, + domain: $document, + skipRenewCheck: true, + )); } catch (Duplicate $e) { Console::info('Certificate already exists'); } finally { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fa96d2ae80..6c7532959b 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -3,8 +3,8 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Audit; use Appwrite\Event\Build; +use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -12,6 +12,7 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Messaging; +use Appwrite\Event\Publisher\Audit; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; @@ -88,7 +89,7 @@ Http::init() ->inject('request') ->inject('dbForPlatform') ->inject('dbForProject') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('project') ->inject('user') ->inject('session') @@ -97,7 +98,7 @@ Http::init() ->inject('team') ->inject('apiKey') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { $route = $utopia->getRoute(); if ($route === null) { throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); @@ -193,7 +194,7 @@ Http::init() 'name' => $apiKey->getName(), ]); - $queueForAudits->setUser($user); + $auditContext->setUser($user); } // For standard keys, update last accessed time @@ -264,7 +265,7 @@ Http::init() API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, default => ACTIVITY_TYPE_KEY_PROJECT, }); - $queueForAudits->setUser($userClone); + $auditContext->setUser($userClone); } // Apply permission @@ -477,7 +478,7 @@ Http::init() ->inject('user') ->inject('queueForEvents') ->inject('queueForMessaging') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('queueForDeletes') ->inject('queueForDatabase') ->inject('queueForBuilds') @@ -494,7 +495,7 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { $response->setUser($user); $request->setUser($user); @@ -595,7 +596,7 @@ Http::init() ->setProject($project) ->setUser($user); - $queueForAudits + $auditContext ->setMode($mode) ->setUserAgent($request->getUserAgent('')) ->setIP($request->getIP()) @@ -610,7 +611,7 @@ Http::init() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $queueForAudits->setUser($userClone); + $auditContext->setUser($userClone); } /* Auto-set projects */ @@ -789,7 +790,8 @@ Http::shutdown() ->inject('project') ->inject('user') ->inject('queueForEvents') - ->inject('queueForAudits') + ->inject('auditContext') + ->inject('publisherForAudits') ->inject('usage') ->inject('publisherForUsage') ->inject('queueForDeletes') @@ -806,7 +808,7 @@ Http::shutdown() ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -901,7 +903,7 @@ Http::shutdown() if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); if (! empty($resource) && $resource !== $pattern) { - $queueForAudits->setResource($resource); + $auditContext->setResource($resource); } } @@ -911,8 +913,8 @@ Http::shutdown() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $queueForAudits->setUser($userClone); - } elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) { + $auditContext->setUser($userClone); + } elseif ($auditContext->getUser() === null || $auditContext->getUser()->isEmpty()) { /** * User in the request is empty, and no user was set for auditing previously. * This indicates: @@ -930,24 +932,31 @@ Http::shutdown() 'name' => 'Guest', ]); - $queueForAudits->setUser($user); + $auditContext->setUser($user); } - if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->isEmpty()) { + $auditUser = $auditContext->getUser(); + if (! empty($auditContext->getResource()) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { - $queueForAudits->setPayload($responsePayload); + $auditContext->setPayload($responsePayload); } - foreach ($queueForEvents->getParams() as $key => $value) { - $queueForAudits->setParam($key, $value); - } - - $queueForAudits->trigger(); + $publisherForAudits->enqueue(new \Appwrite\Event\Message\Audit( + project: $auditContext->getProject() ?? new Document(), + user: $auditUser, + payload: $auditContext->getPayload(), + resource: $auditContext->getResource(), + mode: $auditContext->getMode(), + ip: $auditContext->getIP(), + userAgent: $auditContext->getUserAgent(), + event: $auditContext->getEvent(), + hostname: $auditContext->getHostname(), + )); } if (! empty($queueForDeletes->getType())) { diff --git a/app/init/resources.php b/app/init/resources.php index 32d6e0a45f..d1bb7584bf 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1,8 +1,11 @@ set('publisherMessaging', function (Publisher $publisher) { $container->set('publisherWebhooks', function (Publisher $publisher) { return $publisher; }, ['publisher']); +$container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher( + $publisher, + new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher( + $publisher, + new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME)) +), ['publisher']); +$container->set('publisherForScreenshots', fn (Publisher $publisher) => new ScreenshotPublisher( + $publisher, + new Queue(System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME)) +), ['publisher']); $container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePublisher( $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 63e58e92f7..6c8eef4d92 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -4,17 +4,17 @@ use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Databases\TransactionState; -use Appwrite\Event\Audit as AuditEvent; use Appwrite\Event\Build; -use Appwrite\Event\Certificate; +use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; +use Appwrite\Event\Migration; use Appwrite\Event\Realtime; -use Appwrite\Event\Screenshot; +use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; @@ -128,9 +128,6 @@ return function (Container $container): void { $container->set('queueForBuilds', function (Publisher $publisher) { return new Build($publisher); }, ['publisher']); - $container->set('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); - }, ['publisher']); $container->set('queueForDatabase', function (Publisher $publisher) { return new EventDatabase($publisher); }, ['publisher']); @@ -149,17 +146,18 @@ return function (Container $container): void { $container->set('usage', function () { return new UsageContext(); }, []); - $container->set('queueForAudits', function (Publisher $publisher) { - return new AuditEvent($publisher); - }, ['publisher']); + $container->set('auditContext', fn () => new AuditContext(), []); $container->set('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); $container->set('eventProcessor', function () { return new EventProcessor(); }, []); - $container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); + $container->set('queueForMigrations', function (Publisher $publisher) { + return new Migration($publisher); + }, ['publisher']); + $container->set('queueForStatsResources', function (Publisher $publisher) { + return new StatsResources($publisher); }, ['publisher']); $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index f893c84858..b513809a9b 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,16 +1,14 @@ set('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); - }, ['publisher']); - $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); @@ -323,10 +317,6 @@ return function (Container $container): void { return new Event($publisher); }, ['publisher']); - $container->set('queueForAudits', function (Publisher $publisher) { - return new Audit($publisher); - }, ['publisher']); - $container->set('queueForWebhooks', function (Publisher $publisher) { return new Webhook($publisher); }, ['publisher']); @@ -339,8 +329,8 @@ return function (Container $container): void { return new Realtime(); }, []); - $container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); + $container->set('queueForMigrations', function (Publisher $publisher) { + return new Migration($publisher); }, ['publisher']); $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) { diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php new file mode 100644 index 0000000000..3cfd785ff8 --- /dev/null +++ b/src/Appwrite/Event/Context/Audit.php @@ -0,0 +1,134 @@ +project = $project; + + return $this; + } + + public function getProject(): ?Document + { + return $this->project; + } + + public function setUser(Document $user): self + { + $this->user = $user; + + return $this; + } + + public function getUser(): ?Document + { + return $this->user; + } + + public function setMode(string $mode): self + { + $this->mode = $mode; + + return $this; + } + + public function getMode(): string + { + return $this->mode; + } + + public function setUserAgent(string $userAgent): self + { + $this->userAgent = $userAgent; + + return $this; + } + + public function getUserAgent(): string + { + return $this->userAgent; + } + + public function setIP(string $ip): self + { + $this->ip = $ip; + + return $this; + } + + public function getIP(): string + { + return $this->ip; + } + + public function setHostname(string $hostname): self + { + $this->hostname = $hostname; + + return $this; + } + + public function getHostname(): string + { + return $this->hostname; + } + + public function setEvent(string $event): self + { + $this->event = $event; + + return $this; + } + + public function getEvent(): string + { + return $this->event; + } + + public function setResource(string $resource): self + { + $this->resource = $resource; + + return $this; + } + + public function getResource(): string + { + return $this->resource; + } + + public function setPayload(array $payload): self + { + $this->payload = $payload; + + return $this; + } + + public function getPayload(): array + { + return $this->payload; + } +} diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php new file mode 100644 index 0000000000..febd96b072 --- /dev/null +++ b/src/Appwrite/Event/Message/Audit.php @@ -0,0 +1,55 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'user' => $this->user->getArrayCopy(), + 'payload' => $this->payload, + 'resource' => $this->resource, + 'mode' => $this->mode, + 'ip' => $this->ip, + 'userAgent' => $this->userAgent, + 'event' => $this->event, + 'hostname' => $this->hostname, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + user: new Document($data['user'] ?? []), + payload: $data['payload'] ?? [], + resource: $data['resource'] ?? '', + mode: $data['mode'] ?? '', + ip: $data['ip'] ?? '', + userAgent: $data['userAgent'] ?? '', + event: $data['event'] ?? '', + hostname: $data['hostname'] ?? '', + ); + } +} diff --git a/src/Appwrite/Event/Message/Certificate.php b/src/Appwrite/Event/Message/Certificate.php new file mode 100644 index 0000000000..a189bb8187 --- /dev/null +++ b/src/Appwrite/Event/Message/Certificate.php @@ -0,0 +1,43 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'domain' => $this->domain->getArrayCopy(), + 'skipRenewCheck' => $this->skipRenewCheck, + 'validationDomain' => $this->validationDomain, + 'action' => $this->action, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + domain: new Document($data['domain'] ?? []), + skipRenewCheck: $data['skipRenewCheck'] ?? false, + validationDomain: $data['validationDomain'] ?? null, + action: $data['action'] ?? \Appwrite\Event\Certificate::ACTION_GENERATION, + ); + } +} diff --git a/src/Appwrite/Event/Message/Screenshot.php b/src/Appwrite/Event/Message/Screenshot.php new file mode 100644 index 0000000000..05340fbda5 --- /dev/null +++ b/src/Appwrite/Event/Message/Screenshot.php @@ -0,0 +1,37 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'deploymentId' => $this->deploymentId, + 'platform' => $this->platform, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + deploymentId: $data['deploymentId'] ?? '', + platform: $data['platform'] ?? [], + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Audit.php b/src/Appwrite/Event/Publisher/Audit.php new file mode 100644 index 0000000000..daa9a01fce --- /dev/null +++ b/src/Appwrite/Event/Publisher/Audit.php @@ -0,0 +1,35 @@ +publish($this->queue, $message); + } catch (\Throwable $th) { + Console::error('[Audit] Failed to publish audit message: ' . $th->getMessage()); + + return false; + } + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Certificate.php b/src/Appwrite/Event/Publisher/Certificate.php new file mode 100644 index 0000000000..472fb0d701 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Certificate.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Event/Publisher/Screenshot.php b/src/Appwrite/Event/Publisher/Screenshot.php new file mode 100644 index 0000000000..2a0fa1e0f8 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Screenshot.php @@ -0,0 +1,27 @@ +publish($this->queue, $message); + } + + public function getSize(bool $failed = false): int + { + return $this->getQueueSize($this->queue, $failed); + } +} diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index c6c4a0b38c..41352c36f6 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -6,9 +6,9 @@ use Ahc\Jwt\JWT; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; -use Appwrite\Event\Screenshot; use Appwrite\Event\Webhook; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Usage\Context; @@ -58,7 +58,7 @@ class Builds extends Action ->inject('project') ->inject('dbForPlatform') ->inject('queueForEvents') - ->inject('queueForScreenshots') + ->inject('publisherForScreenshots') ->inject('queueForWebhooks') ->inject('queueForFunctions') ->inject('queueForRealtime') @@ -84,7 +84,7 @@ class Builds extends Action Document $project, Database $dbForPlatform, Event $queueForEvents, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, @@ -126,7 +126,7 @@ class Builds extends Action $deviceForFunctions, $deviceForSites, $deviceForFiles, - $queueForScreenshots, + $publisherForScreenshots, $queueForWebhooks, $queueForFunctions, $queueForRealtime, @@ -161,7 +161,7 @@ class Builds extends Action Device $deviceForFunctions, Device $deviceForSites, Device $deviceForFiles, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, @@ -1120,10 +1120,11 @@ class Builds extends Action /** Screenshot site */ if ($resource->getCollection() === 'sites') { - $queueForScreenshots - ->setDeploymentId($deployment->getId()) - ->setProject($project) - ->trigger(); + $publisherForScreenshots->enqueue(new \Appwrite\Event\Message\Screenshot( + project: $project, + deploymentId: $deployment->getId(), + platform: $platform, + )); Console::log('Site screenshot queued'); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 065fe477eb..423bf0bd41 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; +use Appwrite\Event\Message\Screenshot; use Appwrite\Event\Realtime; use Appwrite\Permission; use Appwrite\Role; @@ -62,9 +63,11 @@ class Screenshots extends Action throw new \Exception('Missing payload'); } + $screenshotMessage = Screenshot::fromArray($payload); + Console::log('Site screenshot started'); - $deploymentId = $payload['deploymentId'] ?? null; + $deploymentId = $screenshotMessage->deploymentId; $deployment = $dbForProject->getDocument('deployments', $deploymentId); if ($deployment->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php index e01e89641d..76c34a0a2a 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Audits/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Audits; -use Appwrite\Event\Audit; +use Appwrite\Event\Publisher\Audit; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void { $threshold = (int) $threshold; - $size = $queueForAudits->getSize(); + $size = $publisherForAudits->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php index 6724f25094..82c45db172 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Certificates/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Certificates; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Certificate $queueForCertificates, Response $response): void + public function action(int|string $threshold, Certificate $publisherForCertificates, Response $response): void { $threshold = (int) $threshold; - $size = $queueForCertificates->getSize(); + $size = $publisherForCertificates->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 1f7cc0bf33..6d77cc6e16 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -2,19 +2,19 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed; -use Appwrite\Event\Audit; use Appwrite\Event\Build; -use Appwrite\Event\Certificate; use Appwrite\Event\Database; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; +use Appwrite\Event\Publisher\Audit; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Event\Publisher\Migration as MigrationPublisher; +use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Event\Screenshot; use Appwrite\Event\Webhook; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; @@ -75,17 +75,17 @@ class Get extends Base ->inject('response') ->inject('queueForDatabase') ->inject('queueForDeletes') - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('queueForMails') ->inject('queueForFunctions') ->inject('publisherForStatsResources') ->inject('publisherForUsage') ->inject('queueForWebhooks') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForBuilds') ->inject('queueForMessaging') ->inject('publisherForMigrations') - ->inject('queueForScreenshots') + ->inject('publisherForScreenshots') ->callback($this->action(...)); } @@ -95,32 +95,32 @@ class Get extends Base Response $response, Database $queueForDatabase, Delete $queueForDeletes, - Audit $queueForAudits, + Audit $publisherForAudits, Mail $queueForMails, Func $queueForFunctions, StatsResourcesPublisher $publisherForStatsResources, UsagePublisher $publisherForUsage, Webhook $queueForWebhooks, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Build $queueForBuilds, Messaging $queueForMessaging, MigrationPublisher $publisherForMigrations, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, ): void { $threshold = (int) $threshold; $queue = match ($name) { System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase, System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes, - System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $queueForAudits, + System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $publisherForAudits, System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails, System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions, System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $publisherForStatsResources, System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage, System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, - System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $queueForCertificates, + System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates, System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, - System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $queueForScreenshots, + System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, }; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php index dd05aebc39..0a655662de 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Logs/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Logs; -use Appwrite\Event\Audit; +use Appwrite\Event\Publisher\Audit; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForAudits') + ->inject('publisherForAudits') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Audit $queueForAudits, Response $response): void + public function action(int|string $threshold, Audit $publisherForAudits, Response $response): void { $threshold = (int) $threshold; - $size = $queueForAudits->getSize(); + $size = $publisherForAudits->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index bfa62ef920..a6a3e44194 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\API; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -62,7 +62,7 @@ class Create extends Action ->param('domain', null, new ValidatorDomain(), 'Domain name.') ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('platform') @@ -70,7 +70,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) + public function action(string $domain, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -114,13 +114,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index a61ce80c4b..4a8bd4897e 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Function; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -66,7 +66,7 @@ class Create extends Action ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -132,13 +132,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 95c29f48e8..8a265ba5bb 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -69,7 +69,7 @@ class Create extends Action ->param('resourceType', '', new WhiteList(['site', 'function']), 'Type of parent resource.') ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -78,7 +78,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -136,13 +136,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index ba99cefb42..a9dfa93a49 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Site; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -66,7 +66,7 @@ class Create extends Action ->param('branch', '', new Text(255, 0), 'Name of VCS branch to deploy changes automatically', true) ->inject('response') ->inject('project') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') @@ -75,7 +75,7 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) + public function action(string $domain, string $siteId, ?string $branch, Response $response, Document $project, Certificate $publisherForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { $this->validateDomainRestrictions($domain, $platform); @@ -132,13 +132,14 @@ class Create extends Action } if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } $queueForEvents->setParam('ruleId', $rule->getId()); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php index 8a0d341132..9e81f6ff18 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Verification; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Proxy\Action; use Appwrite\SDK\AuthType; @@ -56,7 +56,7 @@ class Update extends Action )) ->param('ruleId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Rule ID.', false, ['dbForProject']) ->inject('response') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForEvents') ->inject('project') ->inject('dbForPlatform') @@ -67,7 +67,7 @@ class Update extends Action public function action( string $ruleId, Response $response, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Event $queueForEvents, Document $project, Database $dbForPlatform, @@ -110,12 +110,13 @@ class Update extends Action } // Issue a TLS certificate when DNS verification is successful - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $project, + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->trigger(); + ]), + )); if (!empty($certificate)) { $rule->setAttribute('renewAt', $certificate->getAttribute('renewDate', '')); diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index a7d16e0a52..f5502a5986 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use DateTime; use Swoole\Coroutine\Channel; use Swoole\Process; @@ -29,16 +29,16 @@ class Interval extends Action ->desc('Schedules tasks on regular intervals by publishing them to our queues') ->inject('dbForPlatform') ->inject('getProjectDB') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->callback($this->action(...)); } - public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): void + public function action(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): void { Console::title('Interval V1'); Console::success(APP_NAME . ' interval process v1 has started'); - $timers = $this->runTasks($dbForPlatform, $getProjectDB, $queueForCertificates); + $timers = $this->runTasks($dbForPlatform, $getProjectDB, $publisherForCertificates); $chan = new Channel(1); Process::signal(SIGTERM, function () use ($chan) { @@ -52,16 +52,16 @@ class Interval extends Action } } - public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates): array + public function runTasks(Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates): array { $timers = []; $tasks = $this->getTasks(); foreach ($tasks as $task) { - $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $queueForCertificates) { + $timers[] = Timer::tick($task['interval'], function () use ($task, $dbForPlatform, $getProjectDB, $publisherForCertificates) { $taskName = $task['name']; Span::init("interval.{$taskName}"); try { - $task['callback']($dbForPlatform, $getProjectDB, $queueForCertificates); + $task['callback']($dbForPlatform, $getProjectDB, $publisherForCertificates); } catch (\Exception $e) { Span::error($e); } finally { @@ -80,15 +80,15 @@ class Interval extends Action return [ [ 'name' => 'domainVerification', - "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $queueForCertificates) { - $this->verifyDomain($dbForPlatform, $queueForCertificates); + "callback" => function (Database $dbForPlatform, callable $getProjectDB, Certificate $publisherForCertificates) { + $this->verifyDomain($dbForPlatform, $publisherForCertificates); }, 'interval' => $intervalDomainVerification * 1000, ] ]; } - private function verifyDomain(Database $dbForPlatform, Certificate $queueForCertificates): void + private function verifyDomain(Database $dbForPlatform, Certificate $publisherForCertificates): void { $time = DatabaseDateTime::now(); $fromTime = new DateTime('-3 days'); // Max 3 days old @@ -115,13 +115,17 @@ class Interval extends Action foreach ($rules as $rule) { try { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_DOMAIN_VERIFICATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION, + )); $processed++; } catch (\Throwable $th) { $failed++; diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index c821435786..fe803f1292 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; use Appwrite\Event\Delete; +use Appwrite\Event\Publisher\Certificate; use DateInterval; use DateTime; use Utopia\Console; @@ -29,12 +29,12 @@ class Maintenance extends Action ->param('type', 'loop', new WhiteList(['loop', 'trigger']), 'How to run task. "loop" is meant for container entrypoint, and "trigger" for manual execution.') ->inject('dbForPlatform') ->inject('console') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('queueForDeletes') ->callback($this->action(...)); } - public function action(string $type, Database $dbForPlatform, Document $console, Certificate $queueForCertificates, Delete $queueForDeletes): void + public function action(string $type, Database $dbForPlatform, Document $console, Certificate $publisherForCertificates, Delete $queueForDeletes): void { Console::title('Maintenance V1'); Console::success(APP_NAME . ' maintenance process v1 has started'); @@ -59,7 +59,7 @@ class Maintenance extends Action $delay = $next->getTimestamp() - $now->getTimestamp(); } - $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $queueForCertificates) { + $action = function () use ($interval, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForPlatform, $console, $queueForDeletes, $publisherForCertificates) { $time = DatabaseDateTime::now(); Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds"); @@ -92,7 +92,7 @@ class Maintenance extends Action ->trigger(); $this->notifyDeleteConnections($queueForDeletes); - $this->renewCertificates($dbForPlatform, $queueForCertificates); + $this->renewCertificates($dbForPlatform, $publisherForCertificates); $this->notifyDeleteCache($cacheRetention, $queueForDeletes); $this->notifyDeleteSchedules($schedulesDeletionRetention, $queueForDeletes); $this->notifyDeleteCSVExports($queueForDeletes); @@ -124,7 +124,7 @@ class Maintenance extends Action ->trigger(); } - private function renewCertificates(Database $dbForPlatform, Certificate $queueForCertificate): void + private function renewCertificates(Database $dbForPlatform, Certificate $publisherForCertificate): void { $time = DatabaseDateTime::now(); @@ -158,13 +158,17 @@ class Maintenance extends Action continue; } - $queueForCertificate - ->setDomain(new Document([ + $publisherForCertificate->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); } } diff --git a/src/Appwrite/Platform/Tasks/SSL.php b/src/Appwrite/Platform/Tasks/SSL.php index ef8283f168..cb33836a99 100644 --- a/src/Appwrite/Platform/Tasks/SSL.php +++ b/src/Appwrite/Platform/Tasks/SSL.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Tasks; -use Appwrite\Event\Certificate; +use Appwrite\Event\Publisher\Certificate; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\Document; @@ -29,11 +29,11 @@ class SSL extends Action ->param('skip-check', 'true', new Boolean(true), 'If DNS and renew check should be skipped. Defaults to true, and when true, all jobs will result in certificate generation attempt.', true) ->inject('console') ->inject('dbForPlatform') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->callback($this->action(...)); } - public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $queueForCertificates): void + public function action(string $domain, bool|string $skipCheck, Document $console, Database $dbForPlatform, Certificate $publisherForCertificates): void { $domain = new Domain(!empty($domain) ? $domain : ''); if (!$domain->isKnown() || $domain->isTest()) { @@ -98,12 +98,13 @@ class SSL extends Action Console::info('Updated existing rule ' . $rule->getId() . ' for domain: ' . $domain->get()); } - $queueForCertificates - ->setDomain(new Document([ - 'domain' => $domain->get() - ])) - ->setSkipRenewCheck($skipCheck) - ->trigger(); + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: $console, + domain: new Document([ + 'domain' => $domain->get(), + ]), + skipRenewCheck: $skipCheck, + )); Console::success('Scheduled a job to issue a TLS certificate for domain: ' . $domain->get()); } diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index 6bcc85bc36..e5a7950945 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Appwrite\Event\Message\Audit; use Exception; use Throwable; use Utopia\Console; @@ -40,7 +41,6 @@ class Audits extends Action $this ->desc('Audits worker') ->inject('message') - ->inject('project') ->inject('getAudit') ->callback($this->action(...)); @@ -50,14 +50,13 @@ class Audits extends Action /** * @param Message $message - * @param Document $project * @param callable(Document): \Utopia\Audit\Audit $getAudit * @return Commit|NoCommit * @throws Throwable * @throws \Utopia\Database\Exception * @throws Structure */ - public function action(Message $message, Document $project, callable $getAudit): Commit|NoCommit + public function action(Message $message, callable $getAudit): Commit|NoCommit { $payload = $message->getPayload() ?? []; @@ -65,19 +64,21 @@ class Audits extends Action throw new Exception('Missing payload'); } + $auditMessage = Audit::fromArray($payload); + Console::info('Aggregating audit logs'); - $event = $payload['event'] ?? ''; + $event = $auditMessage->event; $auditPayload = ''; - if ($project->getId() === 'console') { - $auditPayload = $payload['payload'] ?? ''; + if ($auditMessage->project->getId() === 'console') { + $auditPayload = $auditMessage->payload; } - $mode = $payload['mode'] ?? ''; - $resource = $payload['resource'] ?? ''; - $userAgent = $payload['userAgent'] ?? ''; - $ip = $payload['ip'] ?? ''; - $user = new Document($payload['user'] ?? []); + $mode = $auditMessage->mode; + $resource = $auditMessage->resource; + $userAgent = $auditMessage->userAgent; + $ip = $auditMessage->ip; + $user = $auditMessage->user; $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $actorUserId = $impersonatorUserId ?: $user->getId(); @@ -126,14 +127,14 @@ class Audits extends Action ]; } - if (isset($this->logs[$project->getSequence()])) { - $this->logs[$project->getSequence()]['logs'][] = $eventData; + if (isset($this->logs[$auditMessage->project->getSequence()])) { + $this->logs[$auditMessage->project->getSequence()]['logs'][] = $eventData; } else { - $this->logs[$project->getSequence()] = [ + $this->logs[$auditMessage->project->getSequence()] = [ 'project' => new Document([ - '$id' => $project->getId(), - '$sequence' => $project->getSequence(), - 'database' => $project->getAttribute('database'), + '$id' => $auditMessage->project->getId(), + '$sequence' => $auditMessage->project->getSequence(), + 'database' => $auditMessage->project->getAttribute('database'), ]), 'logs' => [$eventData] ]; diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 73509819a9..34234971d9 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -3,10 +3,10 @@ namespace Appwrite\Platform\Workers; use Appwrite\Certificates\Adapter as CertificatesAdapter; -use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Publisher\Certificate; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception as AppwriteException; @@ -55,7 +55,7 @@ class Certificates extends Action ->inject('queueForWebhooks') ->inject('queueForFunctions') ->inject('queueForRealtime') - ->inject('queueForCertificates') + ->inject('publisherForCertificates') ->inject('log') ->inject('certificates') ->inject('plan') @@ -71,7 +71,7 @@ class Certificates extends Action * @param Webhook $queueForWebhooks * @param Func $queueForFunctions * @param Realtime $queueForRealtime - * @param Certificate $queueForCertificates + * @param Certificate $publisherForCertificates * @param Log $log * @param CertificatesAdapter $certificates * @param array $plan @@ -88,7 +88,7 @@ class Certificates extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Log $log, CertificatesAdapter $certificates, array $plan, @@ -100,21 +100,22 @@ class Certificates extends Action throw new Exception('Missing payload'); } - $document = new Document($payload['domain'] ?? []); + $certificateMessage = \Appwrite\Event\Message\Certificate::fromArray($payload); + $document = $certificateMessage->domain; $domain = new Domain($document->getAttribute('domain', '')); $domainType = $document->getAttribute('domainType'); - $skipRenewCheck = $payload['skipRenewCheck'] ?? false; - $validationDomain = $payload['validationDomain'] ?? null; - $action = $payload['action'] ?? Certificate::ACTION_GENERATION; + $skipRenewCheck = $certificateMessage->skipRenewCheck; + $validationDomain = $certificateMessage->validationDomain; + $action = $certificateMessage->action; $log->addTag('domain', $domain->get()); switch ($action) { - case Certificate::ACTION_DOMAIN_VERIFICATION: - $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $queueForCertificates, $log, $authorization, $validationDomain); + case \Appwrite\Event\Certificate::ACTION_DOMAIN_VERIFICATION: + $this->handleDomainVerificationAction($domain, $dbForPlatform, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $publisherForCertificates, $log, $authorization, $validationDomain); break; - case Certificate::ACTION_GENERATION: + case \Appwrite\Event\Certificate::ACTION_GENERATION: $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain); break; @@ -130,7 +131,7 @@ class Certificates extends Action * @param Webhook $queueForWebhooks * @param Func $queueForFunctions * @param Realtime $queueForRealtime - * @param Certificate $queueForCertificates + * @param Certificate $publisherForCertificates * @param Log $log * @param ValidatorAuthorization $authorization * @param string|null $validationDomain @@ -146,7 +147,7 @@ class Certificates extends Action Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, - Certificate $queueForCertificates, + Certificate $publisherForCertificates, Log $log, ValidatorAuthorization $authorization, ?string $validationDomain = null @@ -188,13 +189,17 @@ class Certificates extends Action // Issue a TLS certificate when domain is verified if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) { - $queueForCertificates - ->setDomain(new Document([ + $publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate( + project: new Document([ + '$id' => $rule->getAttribute('projectId', ''), + '$sequence' => $rule->getAttribute('projectInternalId', 0), + ]), + domain: new Document([ 'domain' => $rule->getAttribute('domain'), 'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')), - ])) - ->setAction(Certificate::ACTION_GENERATION) - ->trigger(); + ]), + action: \Appwrite\Event\Certificate::ACTION_GENERATION, + )); Console::success('Certificate generation triggered successfully.'); } From 0de26be6e6c2d6310f0b6ee00442bef0114438e5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 10 Apr 2026 16:40:29 +0530 Subject: [PATCH 2/8] chore: address review feedback --- src/Appwrite/Event/Message/Screenshot.php | 3 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 1 - 2 files changed, 4 deletions(-) diff --git a/src/Appwrite/Event/Message/Screenshot.php b/src/Appwrite/Event/Message/Screenshot.php index 05340fbda5..a06cdfbfc0 100644 --- a/src/Appwrite/Event/Message/Screenshot.php +++ b/src/Appwrite/Event/Message/Screenshot.php @@ -9,7 +9,6 @@ final class Screenshot extends Base public function __construct( public readonly Document $project, public readonly string $deploymentId, - public readonly array $platform = [], ) { } @@ -22,7 +21,6 @@ final class Screenshot extends Base 'database' => $this->project->getAttribute('database', ''), ], 'deploymentId' => $this->deploymentId, - 'platform' => $this->platform, ]; } @@ -31,7 +29,6 @@ final class Screenshot extends Base return new self( project: new Document($data['project'] ?? []), deploymentId: $data['deploymentId'] ?? '', - platform: $data['platform'] ?? [], ); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 41352c36f6..0071b03d2d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -1123,7 +1123,6 @@ class Builds extends Action $publisherForScreenshots->enqueue(new \Appwrite\Event\Message\Screenshot( project: $project, deploymentId: $deployment->getId(), - platform: $platform, )); Console::log('Site screenshot queued'); From ec5472f1edc0bfdffc2b2cfbbf2e7394d2ee54d9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 11 Apr 2026 08:57:06 +0530 Subject: [PATCH 3/8] chore: remove unrelated queue resources --- app/init/resources/request.php | 8 -------- app/init/worker/message.php | 5 ----- 2 files changed, 13 deletions(-) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 6c8eef4d92..3f6196c460 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -12,9 +12,7 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; use Appwrite\Event\Realtime; -use Appwrite\Event\StatsResources; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; @@ -153,12 +151,6 @@ return function (Container $container): void { $container->set('eventProcessor', function () { return new EventProcessor(); }, []); - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('queueForStatsResources', function (Publisher $publisher) { - return new StatsResources($publisher); - }, ['publisher']); $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { $adapter = new DatabasePool($pools->get('console')); $database = new Database($adapter, $cache); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index b513809a9b..c505d4cb3a 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -7,7 +7,6 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; -use Appwrite\Event\Migration; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; use Appwrite\Usage\Context; @@ -329,10 +328,6 @@ return function (Container $container): void { return new Realtime(); }, []); - $container->set('queueForMigrations', function (Publisher $publisher) { - return new Migration($publisher); - }, ['publisher']); - $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) { return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); }, ['project', 'telemetry']); From 5ecd15a5f5baafe7d9944e8092fd7bfe7bf66a4d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 11 Apr 2026 09:07:51 +0530 Subject: [PATCH 4/8] fix: register certificate publisher in cli --- app/cli.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/cli.php b/app/cli.php index ee0b4a6103..a6267fa341 100644 --- a/app/cli.php +++ b/app/cli.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/init.php'; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Publisher\Certificate as CertificatePublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Platform\Appwrite; @@ -252,6 +253,10 @@ $container->set('publisherForUsage', fn (Publisher $publisher) => new UsagePubli $publisher, new Queue(System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME)) ), ['publisher']); +$container->set('publisherForCertificates', fn (Publisher $publisher) => new CertificatePublisher( + $publisher, + new Queue(System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME)) +), ['publisher']); $container->set('publisherForStatsResources', fn (Publisher $publisher) => new StatsResourcesPublisher( $publisher, new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) From d40a355d9de308d6569637e41341d2bd94d7194a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 18:21:39 +0530 Subject: [PATCH 5/8] refactor: simplify audit event context --- src/Appwrite/Event/Context/Audit.php | 142 ++++----------------------- 1 file changed, 21 insertions(+), 121 deletions(-) diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php index 3cfd785ff8..1d41890476 100644 --- a/src/Appwrite/Event/Context/Audit.php +++ b/src/Appwrite/Event/Context/Audit.php @@ -6,129 +6,29 @@ use Utopia\Database\Document; class Audit { - protected ?Document $project = null; - - protected ?Document $user = null; - - protected string $mode = ''; - - protected string $userAgent = ''; - - protected string $ip = ''; - - protected string $hostname = ''; - - protected string $event = ''; - - protected string $resource = ''; - - protected array $payload = []; - - public function setProject(Document $project): self - { - $this->project = $project; - - return $this; + public function __construct( + public ?Document $project = null, + public ?Document $user = null, + public string $mode = '', + public string $userAgent = '', + public string $ip = '', + public string $hostname = '', + public string $event = '', + public string $resource = '', + public array $payload = [], + ) { } - public function getProject(): ?Document + public function isEmpty(): bool { - return $this->project; - } - - public function setUser(Document $user): self - { - $this->user = $user; - - return $this; - } - - public function getUser(): ?Document - { - return $this->user; - } - - public function setMode(string $mode): self - { - $this->mode = $mode; - - return $this; - } - - public function getMode(): string - { - return $this->mode; - } - - public function setUserAgent(string $userAgent): self - { - $this->userAgent = $userAgent; - - return $this; - } - - public function getUserAgent(): string - { - return $this->userAgent; - } - - public function setIP(string $ip): self - { - $this->ip = $ip; - - return $this; - } - - public function getIP(): string - { - return $this->ip; - } - - public function setHostname(string $hostname): self - { - $this->hostname = $hostname; - - return $this; - } - - public function getHostname(): string - { - return $this->hostname; - } - - public function setEvent(string $event): self - { - $this->event = $event; - - return $this; - } - - public function getEvent(): string - { - return $this->event; - } - - public function setResource(string $resource): self - { - $this->resource = $resource; - - return $this; - } - - public function getResource(): string - { - return $this->resource; - } - - public function setPayload(array $payload): self - { - $this->payload = $payload; - - return $this; - } - - public function getPayload(): array - { - return $this->payload; + return $this->project === null + && $this->user === null + && $this->mode === '' + && $this->userAgent === '' + && $this->ip === '' + && $this->hostname === '' + && $this->event === '' + && $this->resource === '' + && $this->payload === []; } } From a1342b4b9d3bc5e6bd80b4a352a457804da4e1a3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 13 Apr 2026 18:32:38 +0530 Subject: [PATCH 6/8] fix: update audit context usage --- app/controllers/shared/api.php | 49 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 6c7532959b..0611983407 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -194,7 +194,7 @@ Http::init() 'name' => $apiKey->getName(), ]); - $auditContext->setUser($user); + $auditContext->user = $user; } // For standard keys, update last accessed time @@ -265,7 +265,7 @@ Http::init() API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, default => ACTIVITY_TYPE_KEY_PROJECT, }); - $auditContext->setUser($userClone); + $auditContext->user = $userClone; } // Apply permission @@ -596,13 +596,12 @@ Http::init() ->setProject($project) ->setUser($user); - $auditContext - ->setMode($mode) - ->setUserAgent($request->getUserAgent('')) - ->setIP($request->getIP()) - ->setHostname($request->getHostname()) - ->setEvent($route->getLabel('audits.event', '')) - ->setProject($project); + $auditContext->mode = $mode; + $auditContext->userAgent = $request->getUserAgent(''); + $auditContext->ip = $request->getIP(); + $auditContext->hostname = $request->getHostname(); + $auditContext->event = $route->getLabel('audits.event', ''); + $auditContext->project = $project; /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { @@ -611,7 +610,7 @@ Http::init() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $auditContext->setUser($userClone); + $auditContext->user = $userClone; } /* Auto-set projects */ @@ -903,7 +902,7 @@ Http::shutdown() if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); if (! empty($resource) && $resource !== $pattern) { - $auditContext->setResource($resource); + $auditContext->resource = $resource; } } @@ -913,8 +912,8 @@ Http::shutdown() if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } - $auditContext->setUser($userClone); - } elseif ($auditContext->getUser() === null || $auditContext->getUser()->isEmpty()) { + $auditContext->user = $userClone; + } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { /** * User in the request is empty, and no user was set for auditing previously. * This indicates: @@ -932,30 +931,30 @@ Http::shutdown() 'name' => 'Guest', ]); - $auditContext->setUser($user); + $auditContext->user = $user; } - $auditUser = $auditContext->getUser(); - if (! empty($auditContext->getResource()) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + $auditUser = $auditContext->user; + if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { - $auditContext->setPayload($responsePayload); + $auditContext->payload = $responsePayload; } $publisherForAudits->enqueue(new \Appwrite\Event\Message\Audit( - project: $auditContext->getProject() ?? new Document(), + project: $auditContext->project ?? new Document(), user: $auditUser, - payload: $auditContext->getPayload(), - resource: $auditContext->getResource(), - mode: $auditContext->getMode(), - ip: $auditContext->getIP(), - userAgent: $auditContext->getUserAgent(), - event: $auditContext->getEvent(), - hostname: $auditContext->getHostname(), + payload: $auditContext->payload, + resource: $auditContext->resource, + mode: $auditContext->mode, + ip: $auditContext->ip, + userAgent: $auditContext->userAgent, + event: $auditContext->event, + hostname: $auditContext->hostname, )); } From 82798fa5a3bd3d24447122061ea2ba3eb500c588 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 18:18:25 +0530 Subject: [PATCH 7/8] Simplify audit message construction --- app/controllers/shared/api.php | 248 ++++----------------------- src/Appwrite/Event/Message/Audit.php | 36 ++-- 2 files changed, 58 insertions(+), 226 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index d3248b54ce..1798d31c58 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -10,6 +10,7 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Audit as AuditMessage; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Messaging; use Appwrite\Event\Publisher\Audit; @@ -64,7 +65,6 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar if (array_key_exists($replace, $params)) { $replacement = $params[$replace]; - // Convert to string if it's not already a string if (! is_string($replacement)) { if (is_array($replacement)) { $replacement = json_encode($replacement); @@ -104,83 +104,27 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); } - /** - * Handle user authentication and session validation. - * - * This function follows a series of steps to determine the appropriate user session - * based on cookies, headers, and JWT tokens. - * - * Process: - * - * Project & Role Validation: - * 1. Check if the project is empty. If so, throw an exception. - * 2. Get the roles configuration. - * 3. Determine the role for the user based on the user document. - * 4. Get the scopes for the role. - * - * API Key Authentication: - * 5. If there is an API key: - * - Verify no user session exists simultaneously - * - Check if key is expired - * - Set role and scopes from API key - * - Handle special app role case - * - For standard keys, update last accessed time - * - * User Activity: - * 6. If the project is not the console and user is not admin: - * - Update user's last activity timestamp - * - * Access Control: - * 7. Get the method from the route - * 8. Validate namespace permissions - * 9. Validate scope permissions - * 10. Check if user is blocked - * - * Security Checks: - * 11. Verify password status (check if reset required) - * 12. Validate MFA requirements: - * - Check if MFA is enabled - * - Verify email status - * - Verify phone status - * - Verify authenticator status - * 13. Handle Multi-Factor Authentication: - * - Check remaining required factors - * - Validate factor completion - * - Throw exception if factors incomplete - */ - - // Step 1: Check if project is empty if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } - // Step 2: Get roles configuration $roles = Config::getParam('roles', []); - // Step 3: Determine role for user - // TODO get scopes from the identity instead of the user roles config. The identity will containn the scopes the user authorized for the access token. - $role = $user->isEmpty() ? Role::guests()->toString() : Role::users()->toString(); - // Step 4: Get scopes for the role $scopes = $roles[$role]['scopes']; - // Step 5: API Key Authentication if (! empty($apiKey)) { - // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); } - // Set role and scopes from API key $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); - // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { - // Disable authorization checks for project API keys if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } @@ -197,7 +141,6 @@ Http::init() $auditContext->user = $user; } - // For standard keys, update last accessed time if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { $dbKey = null; if (! empty($apiKey->getProjectId())) { @@ -268,7 +211,6 @@ Http::init() $auditContext->user = $userClone; } - // Apply permission if ($apiKey->getType() === API_KEY_ORGANIZATION) { $authorization->addRole(Role::team($team->getId())->toString()); $authorization->addRole(Role::team($team->getId(), 'owner')->toString()); @@ -296,8 +238,7 @@ Http::init() $authorization->addRole('label:' . $nodeLabel); } } - } // Admin User Authentication - elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { + } elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { $teamId = $team->getId(); $adminRoles = []; $memberships = $user->getAttribute('memberships', []); @@ -318,8 +259,6 @@ Http::init() $projectId = explode('/', $uri)[3]; } - // Base scopes for admin users to allow listing teams and projects. - // Useful for those who have project-specific roles but don't have team-wide role. $scopes = ['teams.read', 'projects.read']; foreach ($adminRoles as $adminRole) { $isTeamWideRole = ! str_starts_with($adminRole, 'project-'); @@ -336,22 +275,15 @@ Http::init() } } - /** - * For console projects resource, we use platform DB. - * Enabling authorization restricts admin user to the projects they have access to. - */ if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { $authorization->setDefaultStatus(true); } else { - // Otherwise, disable authorization checks. $authorization->setDefaultStatus(false); } } $scopes = \array_unique($scopes); - // Intentional: impersonators get users.read so they can discover a target user - // before impersonation starts, and keep that access while impersonating. if ( !$user->isEmpty() && ( @@ -368,11 +300,6 @@ Http::init() $authorization->addRole($authRole); } - /** - * We disable authorization checks above to ensure other endpoints (list teams, members, etc.) will continue working. - * But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check - * whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them). - */ if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { $input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ)); $initialStatus = $authorization->getStatus(); @@ -383,7 +310,6 @@ Http::init() $authorization->setStatus($initialStatus); } - // Step 6: Update project and user last activity if (! $project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { @@ -397,7 +323,6 @@ Http::init() $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $accessedAt = $user->getAttribute('accessedAt', 0); - // Skip updating accessedAt for impersonated requests so we don't attribute activity to the target user. if (! $impersonatorUserId && DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { $user->setAttribute('accessedAt', DateTime::now()); @@ -413,14 +338,8 @@ Http::init() } } - // Steps 7-9: Access Control - Method, Namespace and Scope Validation - /** - * @var ?Method $method - */ $method = $route->getLabel('sdk', false); - // Take the first method if there's more than one, - // namespace can not differ between methods on the same route if (\is_array($method)) { $method = $method[0]; } @@ -437,7 +356,6 @@ Http::init() } } - // Step 8b: Check REST protocol status if ( array_key_exists('rest', $project->getAttribute('apis', [])) && ! $project->getAttribute('apis', [])['rest'] @@ -446,23 +364,19 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } - // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')'); } - // Step 10: Check if user is blocked - if ($user->getAttribute('status') === false) { // Account is blocked + if ($user->getAttribute('status') === false) { throw new Exception(Exception::USER_BLOCKED); } - // Step 11: Verify password status if ($user->getAttribute('reset')) { throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); } - // Step 12: Validate MFA requirements $mfaEnabled = $user->getAttribute('mfa', false); $hasVerifiedEmail = $user->getAttribute('emailVerification', false); $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); @@ -470,7 +384,6 @@ Http::init() $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; - // Step 13: Handle Multi-Factor Authentication if (! in_array('mfa', $route->getGroups())) { if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) { throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); @@ -515,83 +428,36 @@ Http::init() } $path = $route->getMatchedPath(); - $databaseType = match (true) { - str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, - str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, - default => '', - }; - - /* - * Abuse Check - */ - - $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); - $timeLimitArray = []; - - $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; - - foreach ($abuseKeyLabel as $abuseKey) { - $start = $request->getContentRangeStart(); - $end = $request->getContentRangeEnd(); - $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); - $timeLimit - ->setParam('{projectId}', $project->getId()) - ->setParam('{userId}', $user->getId()) - ->setParam('{userAgent}', $request->getUserAgent('')) - ->setParam('{ip}', $request->getIP()) - ->setParam('{url}', $request->getHostname() . $route->getPath()) - ->setParam('{method}', $request->getMethod()) - ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - $timeLimitArray[] = $timeLimit; + if (strpos($request->getProtocol(), 'http') === 0) { + $path = $request->getProtocol() . '://' . $request->getHostname() . $path; } - - $closestLimit = null; - - $roles = $authorization->getRoles(); - $isPrivilegedUser = $user->isPrivileged($roles); - $isAppUser = $user->isApp($roles); - - foreach ($timeLimitArray as $timeLimit) { - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys - if (! empty($value)) { - $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); - } - } - - $abuse = new Abuse($timeLimit); - $remaining = $timeLimit->remaining(); - - $limit = $timeLimit->limit(); - $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600); - - if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) { - $closestLimit = $remaining; - $response - ->addHeader('X-RateLimit-Limit', $limit) - ->addHeader('X-RateLimit-Remaining', $remaining) - ->addHeader('X-RateLimit-Reset', $time); - } - - $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; - - if ( - $enabled // Abuse is enabled - && ! $isAppUser // User is not API key - && ! $isPrivilegedUser // User is not an admin - && $devKey->isEmpty() // request doesn't not contain development key - && $abuse->check() // Route is rate-limited - ) { - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED); + if (strpos($path, ':') !== false) { + $params = $route->getParams(); + foreach ($params as $key => $param) { + $path = str_replace(':' . $key, $param, $path); } } - /** - * TODO: (@loks0n) - * Avoid mutating the message across file boundaries - it's difficult to reason about at scale. - */ - /* - * Background Jobs - */ + $response + ->addHeader('X-Debug-Speed', APP_VERSION_STABLE) + ->addHeader('X-Appwrite-Project', $project->getId()) + ->addHeader('X-Appwrite-Region', System::getEnv('_APP_REGION', 'fra')); + + if (! empty(APP_OPTIONS_ABUSE)) { + $response->addHeader('X-Appwrite-Abuse-Limit', APP_OPTIONS_ABUSE); + } + + $request + ->setProtocol($request->getProtocol()) + ->setHostname($request->getHostname()) + ->setPath($path) + ->setMethod($request->getMethod()) + ->setProject($project) + ->setUser($user); + + $route->setLabel('sdk.url', $path); + $route->setLabel('sdk.name', $project->getAttribute('name')); + $queueForEvents ->setEvent($route->getLabel('event', '')) ->setProject($project) @@ -604,17 +470,14 @@ Http::init() $auditContext->event = $route->getLabel('audits.event', ''); $auditContext->project = $project; - /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { $userClone = clone $user; - // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } - /* Auto-set projects */ $queueForDeletes->setProject($project); $queueForDatabase->setProject($project); $queueForMessaging->setProject($project); @@ -622,7 +485,6 @@ Http::init() $queueForBuilds->setProject($project); $queueForMails->setProject($project); - /* Auto-set platforms */ $queueForFunctions->setPlatform($platform); $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); @@ -640,7 +502,7 @@ Http::init() $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) ); - $timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched. + $timestamp = 60 * 60 * 24 * 180; $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { @@ -686,7 +548,6 @@ Http::init() } Span::add('storage.bucket.id', $bucketId); Span::add('storage.file.id', $fileId); - // Do not update transformedAt if it's a console user if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { @@ -703,7 +564,6 @@ Http::init() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => DateTime::now(), ]))); - // Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid $cache->save($key, $data); } @@ -742,12 +602,6 @@ Http::init() } }); -/** - * Limit user session - * - * Delete older sessions if the number of sessions have crossed - * the session limit set for the project - */ Http::shutdown() ->groups(['session']) ->inject('utopia') @@ -817,11 +671,9 @@ Http::shutdown() $queueForEvents->setPayload($responsePayload); } - // Get project and function/webhook events (cached) $functionsEvents = $eventProcessor->getFunctionsEvents($project, $dbForProject); $webhooksEvents = $eventProcessor->getWebhooksEvents($project); - // Generate events for this operation $generatedEvents = Event::generateEvents( $queueForEvents->getEvent(), $queueForEvents->getParams() @@ -833,7 +685,6 @@ Http::shutdown() ->trigger(); } - // Only trigger functions if there are matching function events if (! empty($functionsEvents)) { foreach ($generatedEvents as $event) { if (isset($functionsEvents[$event])) { @@ -845,7 +696,6 @@ Http::shutdown() } } - // Only trigger webhooks if there are matching webhook events if (! empty($webhooksEvents)) { foreach ($generatedEvents as $event) { if (isset($webhooksEvents[$event])) { @@ -861,9 +711,6 @@ Http::shutdown() $route = $utopia->getRoute(); $requestParams = $route->getParamsValues(); - /** - * Abuse labels - */ $abuseEnabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; $abuseResetCode = $route->getLabel('abuse-reset', []); $abuseResetCode = \is_array($abuseResetCode) ? $abuseResetCode : [$abuseResetCode]; @@ -885,7 +732,7 @@ Http::shutdown() ->setParam('{method}', $request->getMethod()) ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + foreach ($request->getParams() as $key => $value) { if (! empty($value)) { $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); } @@ -896,9 +743,6 @@ Http::shutdown() } } - /** - * Audit labels - */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); @@ -909,20 +753,11 @@ Http::shutdown() if (! $user->isEmpty()) { $userClone = clone $user; - // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { - /** - * User in the request is empty, and no user was set for auditing previously. - * This indicates: - * - No API Key was used. - * - No active session exists. - * - * Therefore, we consider this an anonymous request and create a relevant user. - */ $user = new User([ '$id' => '', 'status' => true, @@ -937,26 +772,12 @@ Http::shutdown() $auditUser = $auditContext->user; if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { - /** - * audits.payload is switched to default true - * in order to auto audit payload for all endpoints - */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { $auditContext->payload = $responsePayload; } - $publisherForAudits->enqueue(new \Appwrite\Event\Message\Audit( - project: $auditContext->project ?? new Document(), - user: $auditUser, - payload: $auditContext->payload, - resource: $auditContext->resource, - mode: $auditContext->mode, - ip: $auditContext->ip, - userAgent: $auditContext->userAgent, - event: $auditContext->event, - hostname: $auditContext->hostname, - )); + $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext)); } if (! empty($queueForDeletes->getType())) { @@ -975,7 +796,6 @@ Http::shutdown() $queueForMessaging->trigger(); } - // Cache label $useCache = $route->getLabel('cache', false); if ($useCache) { $resource = $resourceType = null; @@ -1011,7 +831,6 @@ Http::shutdown() 'signature' => $signature, ]))); } catch (DuplicateException) { - // Race condition: another concurrent request already created the cache document $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); } } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { @@ -1019,7 +838,6 @@ Http::shutdown() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => $cacheLog->getAttribute('accessedAt') ]))); - // Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load() $cache->save($key, $data['payload']); } @@ -1038,11 +856,9 @@ Http::shutdown() )); } - // Publish usage metrics if context has data if (! $usage->isEmpty()) { $metrics = $usage->getMetrics(); - // Filter out API key disabled metrics using suffix pattern matching $disabledMetrics = $apiKey?->getDisabledMetrics() ?? []; if (! empty($disabledMetrics)) { $metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) { diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php index febd96b072..ae5831c3b9 100644 --- a/src/Appwrite/Event/Message/Audit.php +++ b/src/Appwrite/Event/Message/Audit.php @@ -2,20 +2,21 @@ namespace Appwrite\Event\Message; +use Appwrite\Event\Context\Audit as AuditContext; use Utopia\Database\Document; final class Audit extends Base { public function __construct( - public readonly Document $project, - public readonly Document $user, - public readonly array $payload, - public readonly string $resource, - public readonly string $mode, - public readonly string $ip, - public readonly string $userAgent, public readonly string $event, - public readonly string $hostname, + public readonly array $payload, + public readonly Document $project = new Document(), + public readonly Document $user = new Document(), + public readonly string $resource = '', + public readonly string $mode = '', + public readonly string $ip = '', + public readonly string $userAgent = '', + public readonly string $hostname = '', ) { } @@ -41,15 +42,30 @@ final class Audit extends Base public static function fromArray(array $data): static { return new self( + event: $data['event'] ?? '', + payload: $data['payload'] ?? [], project: new Document($data['project'] ?? []), user: new Document($data['user'] ?? []), - payload: $data['payload'] ?? [], resource: $data['resource'] ?? '', mode: $data['mode'] ?? '', ip: $data['ip'] ?? '', userAgent: $data['userAgent'] ?? '', - event: $data['event'] ?? '', hostname: $data['hostname'] ?? '', ); } + + public static function fromContext(AuditContext $context): static + { + return new self( + event: $context->event, + payload: $context->payload, + project: $context->project ?? new Document(), + user: $context->user ?? new Document(), + resource: $context->resource, + mode: $context->mode, + ip: $context->ip, + userAgent: $context->userAgent, + hostname: $context->hostname, + ); + } } From b2884ddb886c4ab244195af39bdace2d51ae6dfc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Apr 2026 18:23:24 +0530 Subject: [PATCH 8/8] Use audit message context helper --- app/controllers/shared/api.php | 235 ++++++++++++++++++++++++++++----- 1 file changed, 205 insertions(+), 30 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 1798d31c58..5567281e67 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -65,6 +65,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar if (array_key_exists($replace, $params)) { $replacement = $params[$replace]; + // Convert to string if it's not already a string if (! is_string($replacement)) { if (is_array($replacement)) { $replacement = json_encode($replacement); @@ -104,27 +105,83 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); } + /** + * Handle user authentication and session validation. + * + * This function follows a series of steps to determine the appropriate user session + * based on cookies, headers, and JWT tokens. + * + * Process: + * + * Project & Role Validation: + * 1. Check if the project is empty. If so, throw an exception. + * 2. Get the roles configuration. + * 3. Determine the role for the user based on the user document. + * 4. Get the scopes for the role. + * + * API Key Authentication: + * 5. If there is an API key: + * - Verify no user session exists simultaneously + * - Check if key is expired + * - Set role and scopes from API key + * - Handle special app role case + * - For standard keys, update last accessed time + * + * User Activity: + * 6. If the project is not the console and user is not admin: + * - Update user's last activity timestamp + * + * Access Control: + * 7. Get the method from the route + * 8. Validate namespace permissions + * 9. Validate scope permissions + * 10. Check if user is blocked + * + * Security Checks: + * 11. Verify password status (check if reset required) + * 12. Validate MFA requirements: + * - Check if MFA is enabled + * - Verify email status + * - Verify phone status + * - Verify authenticator status + * 13. Handle Multi-Factor Authentication: + * - Check remaining required factors + * - Validate factor completion + * - Throw exception if factors incomplete + */ + + // Step 1: Check if project is empty if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } + // Step 2: Get roles configuration $roles = Config::getParam('roles', []); + // Step 3: Determine role for user + // TODO get scopes from the identity instead of the user roles config. The identity will containn the scopes the user authorized for the access token. + $role = $user->isEmpty() ? Role::guests()->toString() : Role::users()->toString(); + // Step 4: Get scopes for the role $scopes = $roles[$role]['scopes']; + // Step 5: API Key Authentication if (! empty($apiKey)) { + // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); } + // Set role and scopes from API key $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); + // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { + // Disable authorization checks for project API keys if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } @@ -141,6 +198,7 @@ Http::init() $auditContext->user = $user; } + // For standard keys, update last accessed time if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { $dbKey = null; if (! empty($apiKey->getProjectId())) { @@ -211,6 +269,7 @@ Http::init() $auditContext->user = $userClone; } + // Apply permission if ($apiKey->getType() === API_KEY_ORGANIZATION) { $authorization->addRole(Role::team($team->getId())->toString()); $authorization->addRole(Role::team($team->getId(), 'owner')->toString()); @@ -238,7 +297,8 @@ Http::init() $authorization->addRole('label:' . $nodeLabel); } } - } elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { + } // Admin User Authentication + elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { $teamId = $team->getId(); $adminRoles = []; $memberships = $user->getAttribute('memberships', []); @@ -259,6 +319,8 @@ Http::init() $projectId = explode('/', $uri)[3]; } + // Base scopes for admin users to allow listing teams and projects. + // Useful for those who have project-specific roles but don't have team-wide role. $scopes = ['teams.read', 'projects.read']; foreach ($adminRoles as $adminRole) { $isTeamWideRole = ! str_starts_with($adminRole, 'project-'); @@ -275,15 +337,22 @@ Http::init() } } + /** + * For console projects resource, we use platform DB. + * Enabling authorization restricts admin user to the projects they have access to. + */ if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { $authorization->setDefaultStatus(true); } else { + // Otherwise, disable authorization checks. $authorization->setDefaultStatus(false); } } $scopes = \array_unique($scopes); + // Intentional: impersonators get users.read so they can discover a target user + // before impersonation starts, and keep that access while impersonating. if ( !$user->isEmpty() && ( @@ -300,6 +369,11 @@ Http::init() $authorization->addRole($authRole); } + /** + * We disable authorization checks above to ensure other endpoints (list teams, members, etc.) will continue working. + * But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check + * whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them). + */ if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { $input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ)); $initialStatus = $authorization->getStatus(); @@ -310,6 +384,7 @@ Http::init() $authorization->setStatus($initialStatus); } + // Step 6: Update project and user last activity if (! $project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { @@ -323,6 +398,7 @@ Http::init() $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $accessedAt = $user->getAttribute('accessedAt', 0); + // Skip updating accessedAt for impersonated requests so we don't attribute activity to the target user. if (! $impersonatorUserId && DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { $user->setAttribute('accessedAt', DateTime::now()); @@ -338,8 +414,14 @@ Http::init() } } + // Steps 7-9: Access Control - Method, Namespace and Scope Validation + /** + * @var ?Method $method + */ $method = $route->getLabel('sdk', false); + // Take the first method if there's more than one, + // namespace can not differ between methods on the same route if (\is_array($method)) { $method = $method[0]; } @@ -356,6 +438,7 @@ Http::init() } } + // Step 8b: Check REST protocol status if ( array_key_exists('rest', $project->getAttribute('apis', [])) && ! $project->getAttribute('apis', [])['rest'] @@ -364,19 +447,23 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } + // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')'); } - if ($user->getAttribute('status') === false) { + // Step 10: Check if user is blocked + if ($user->getAttribute('status') === false) { // Account is blocked throw new Exception(Exception::USER_BLOCKED); } + // Step 11: Verify password status if ($user->getAttribute('reset')) { throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); } + // Step 12: Validate MFA requirements $mfaEnabled = $user->getAttribute('mfa', false); $hasVerifiedEmail = $user->getAttribute('emailVerification', false); $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); @@ -384,6 +471,7 @@ Http::init() $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; + // Step 13: Handle Multi-Factor Authentication if (! in_array('mfa', $route->getGroups())) { if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) { throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); @@ -428,36 +516,83 @@ Http::init() } $path = $route->getMatchedPath(); - if (strpos($request->getProtocol(), 'http') === 0) { - $path = $request->getProtocol() . '://' . $request->getHostname() . $path; + $databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => '', + }; + + /* + * Abuse Check + */ + + $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); + $timeLimitArray = []; + + $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; + + foreach ($abuseKeyLabel as $abuseKey) { + $start = $request->getContentRangeStart(); + $end = $request->getContentRangeEnd(); + $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); + $timeLimit + ->setParam('{projectId}', $project->getId()) + ->setParam('{userId}', $user->getId()) + ->setParam('{userAgent}', $request->getUserAgent('')) + ->setParam('{ip}', $request->getIP()) + ->setParam('{url}', $request->getHostname() . $route->getPath()) + ->setParam('{method}', $request->getMethod()) + ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); + $timeLimitArray[] = $timeLimit; } - if (strpos($path, ':') !== false) { - $params = $route->getParams(); - foreach ($params as $key => $param) { - $path = str_replace(':' . $key, $param, $path); + + $closestLimit = null; + + $roles = $authorization->getRoles(); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); + + foreach ($timeLimitArray as $timeLimit) { + foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + if (! empty($value)) { + $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); + } + } + + $abuse = new Abuse($timeLimit); + $remaining = $timeLimit->remaining(); + + $limit = $timeLimit->limit(); + $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600); + + if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) { + $closestLimit = $remaining; + $response + ->addHeader('X-RateLimit-Limit', $limit) + ->addHeader('X-RateLimit-Remaining', $remaining) + ->addHeader('X-RateLimit-Reset', $time); + } + + $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; + + if ( + $enabled // Abuse is enabled + && ! $isAppUser // User is not API key + && ! $isPrivilegedUser // User is not an admin + && $devKey->isEmpty() // request doesn't not contain development key + && $abuse->check() // Route is rate-limited + ) { + throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED); } } - $response - ->addHeader('X-Debug-Speed', APP_VERSION_STABLE) - ->addHeader('X-Appwrite-Project', $project->getId()) - ->addHeader('X-Appwrite-Region', System::getEnv('_APP_REGION', 'fra')); - - if (! empty(APP_OPTIONS_ABUSE)) { - $response->addHeader('X-Appwrite-Abuse-Limit', APP_OPTIONS_ABUSE); - } - - $request - ->setProtocol($request->getProtocol()) - ->setHostname($request->getHostname()) - ->setPath($path) - ->setMethod($request->getMethod()) - ->setProject($project) - ->setUser($user); - - $route->setLabel('sdk.url', $path); - $route->setLabel('sdk.name', $project->getAttribute('name')); - + /** + * TODO: (@loks0n) + * Avoid mutating the message across file boundaries - it's difficult to reason about at scale. + */ + /* + * Background Jobs + */ $queueForEvents ->setEvent($route->getLabel('event', '')) ->setProject($project) @@ -470,14 +605,17 @@ Http::init() $auditContext->event = $route->getLabel('audits.event', ''); $auditContext->project = $project; + /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { $userClone = clone $user; + // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } + /* Auto-set projects */ $queueForDeletes->setProject($project); $queueForDatabase->setProject($project); $queueForMessaging->setProject($project); @@ -485,6 +623,7 @@ Http::init() $queueForBuilds->setProject($project); $queueForMails->setProject($project); + /* Auto-set platforms */ $queueForFunctions->setPlatform($platform); $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); @@ -502,7 +641,7 @@ Http::init() $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) ); - $timestamp = 60 * 60 * 24 * 180; + $timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched. $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { @@ -548,6 +687,7 @@ Http::init() } Span::add('storage.bucket.id', $bucketId); Span::add('storage.file.id', $fileId); + // Do not update transformedAt if it's a console user if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { @@ -564,6 +704,7 @@ Http::init() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => DateTime::now(), ]))); + // Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid $cache->save($key, $data); } @@ -602,6 +743,12 @@ Http::init() } }); +/** + * Limit user session + * + * Delete older sessions if the number of sessions have crossed + * the session limit set for the project + */ Http::shutdown() ->groups(['session']) ->inject('utopia') @@ -671,9 +818,11 @@ Http::shutdown() $queueForEvents->setPayload($responsePayload); } + // Get project and function/webhook events (cached) $functionsEvents = $eventProcessor->getFunctionsEvents($project, $dbForProject); $webhooksEvents = $eventProcessor->getWebhooksEvents($project); + // Generate events for this operation $generatedEvents = Event::generateEvents( $queueForEvents->getEvent(), $queueForEvents->getParams() @@ -685,6 +834,7 @@ Http::shutdown() ->trigger(); } + // Only trigger functions if there are matching function events if (! empty($functionsEvents)) { foreach ($generatedEvents as $event) { if (isset($functionsEvents[$event])) { @@ -696,6 +846,7 @@ Http::shutdown() } } + // Only trigger webhooks if there are matching webhook events if (! empty($webhooksEvents)) { foreach ($generatedEvents as $event) { if (isset($webhooksEvents[$event])) { @@ -711,6 +862,9 @@ Http::shutdown() $route = $utopia->getRoute(); $requestParams = $route->getParamsValues(); + /** + * Abuse labels + */ $abuseEnabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; $abuseResetCode = $route->getLabel('abuse-reset', []); $abuseResetCode = \is_array($abuseResetCode) ? $abuseResetCode : [$abuseResetCode]; @@ -732,7 +886,7 @@ Http::shutdown() ->setParam('{method}', $request->getMethod()) ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - foreach ($request->getParams() as $key => $value) { + foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys if (! empty($value)) { $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); } @@ -743,6 +897,9 @@ Http::shutdown() } } + /** + * Audit labels + */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); @@ -753,11 +910,20 @@ Http::shutdown() if (! $user->isEmpty()) { $userClone = clone $user; + // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { + /** + * User in the request is empty, and no user was set for auditing previously. + * This indicates: + * - No API Key was used. + * - No active session exists. + * + * Therefore, we consider this an anonymous request and create a relevant user. + */ $user = new User([ '$id' => '', 'status' => true, @@ -772,6 +938,10 @@ Http::shutdown() $auditUser = $auditContext->user; if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + /** + * audits.payload is switched to default true + * in order to auto audit payload for all endpoints + */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { $auditContext->payload = $responsePayload; @@ -796,6 +966,7 @@ Http::shutdown() $queueForMessaging->trigger(); } + // Cache label $useCache = $route->getLabel('cache', false); if ($useCache) { $resource = $resourceType = null; @@ -831,6 +1002,7 @@ Http::shutdown() 'signature' => $signature, ]))); } catch (DuplicateException) { + // Race condition: another concurrent request already created the cache document $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); } } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { @@ -838,6 +1010,7 @@ Http::shutdown() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => $cacheLog->getAttribute('accessedAt') ]))); + // Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load() $cache->save($key, $data['payload']); } @@ -856,9 +1029,11 @@ Http::shutdown() )); } + // Publish usage metrics if context has data if (! $usage->isEmpty()) { $metrics = $usage->getMetrics(); + // Filter out API key disabled metrics using suffix pattern matching $disabledMetrics = $apiKey?->getDisabledMetrics() ?? []; if (! empty($disabledMetrics)) { $metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) {