diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1527e15710..b02d021f1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -509,7 +509,7 @@ jobs: # Services that rely on sequential test method execution (shared static state) FUNCTIONAL_FLAG="--functional" case "${{ matrix.service }}" in - Databases|TablesDB|Functions|Realtime) FUNCTIONAL_FLAG="" ;; + Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;; esac docker compose exec -T \ diff --git a/app/cli.php b/app/cli.php index 73908510d9..a6267fa341 100644 --- a/app/cli.php +++ b/app/cli.php @@ -2,10 +2,10 @@ require_once __DIR__ . '/init.php'; -use Appwrite\Event\Certificate; 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; @@ -253,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)) @@ -263,9 +267,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/config/locale/templates.php b/app/config/locale/templates.php index 6aa376678a..680034554b 100644 --- a/app/config/locale/templates.php +++ b/app/config/locale/templates.php @@ -9,11 +9,5 @@ return [ 'mfaChallenge', 'sessionAlert', 'otpSession' - ], - 'sms' => [ - 'verification', - 'login', - 'invitation', - 'mfaChallenge' ] ]; diff --git a/app/config/templates/site.php b/app/config/templates/site.php index 26f8e39817..b26d31f475 100644 --- a/app/config/templates/site.php +++ b/app/config/templates/site.php @@ -1487,13 +1487,13 @@ return [ ] ], [ - 'key' => 'crm-dashboard-react-admin', - 'name' => 'CRM dashboard with React Admin', - 'tagline' => 'A React-based admin dashboard template with CRM features.', + 'key' => 'dashboard-react-admin', + 'name' => 'E-commerce dashboard with React Admin', + 'tagline' => 'A React-based admin dashboard template with e-commerce features.', 'score' => 4, // 0 to 10 based on looks of screenshot (avoid 1,2,3,8,9,10 if possible) - 'useCases' => [SiteUseCases::DASHBOARD], - 'screenshotDark' => $url . '/images/sites/templates/crm-dashboard-react-admin-dark.png', - 'screenshotLight' => $url . '/images/sites/templates/crm-dashboard-react-admin-light.png', + 'useCases' => [SiteUseCases::DASHBOARD, SiteUseCases::ECOMMERCE], + 'screenshotDark' => $url . '/images/sites/templates/dashboard-react-admin-dark.png', + 'screenshotLight' => $url . '/images/sites/templates/dashboard-react-admin-light.png', 'frameworks' => [ getFramework('REACT', [ 'providerRootDirectory' => './react/react-admin', diff --git a/app/config/variables.php b/app/config/variables.php index 7a3ed13049..c834656ff4 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -872,18 +872,18 @@ return [ ], [ 'name' => '_APP_FUNCTIONS_BUILD_TIMEOUT', - 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 900 seconds.', + 'description' => 'Deprecated since 1.7.0. The maximum number of seconds allowed as a timeout value when building a new function. The default value is 2700 seconds.', 'introduction' => '0.13.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' ], [ 'name' => '_APP_COMPUTE_BUILD_TIMEOUT', - 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 900 seconds.', + 'description' => 'The maximum number of seconds allowed as a timeout value when building a new function or site. The default value is 2700 seconds.', 'introduction' => '1.7.0', - 'default' => '900', + 'default' => '2700', 'required' => false, 'question' => '', 'filter' => '' diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0035778523..ffe2b54c5b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -474,19 +474,26 @@ Http::delete('/v1/account') ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForDeletes') - ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) { + ->inject('authorization') + ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) { if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } if ($project->getId() === 'console') { - // get all memberships $memberships = $user->getAttribute('memberships', []); foreach ($memberships as $membership) { - // prevent deletion if at least one active membership - if ($membership->getAttribute('confirm', false)) { - throw new Exception(Exception::USER_DELETION_PROHIBITED); + if (!$membership->getAttribute('confirm', false)) { + continue; } + + $team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId')); + if ($team->isEmpty()) { + continue; + } + + // Team is left as-is — we don't promote non-owner members to owner. + // Orphan teams are cleaned up later by Cloud's inactive project cleanup. } } @@ -2265,7 +2272,10 @@ Http::post('/v1/account/tokens/magic-url') $subject = $locale->getText("emails.magicSession.subject"); $preview = $locale->getText("emails.magicSession.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? []; + + $customTemplate = + $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2575,7 +2585,9 @@ Http::post('/v1/account/tokens/email') $preview = $locale->getText("emails.otpSession.preview"); $heading = $locale->getText("emails.otpSession.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -2968,11 +2980,6 @@ Http::post('/v1/account/tokens/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $projectName = $project->getAttribute('name'); if ($project->getId() === 'console') { $projectName = $platform['platformName']; @@ -3726,7 +3733,9 @@ Http::post('/v1/account/recovery') $body = $locale->getText("emails.recovery.body"); $subject = $locale->getText("emails.recovery.subject"); $preview = $locale->getText("emails.recovery.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -4034,7 +4043,9 @@ Http::post('/v1/account/verifications/email') $subject = $locale->getText("emails.verification.subject"); $heading = $locale->getText("emails.verification.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -4333,11 +4344,6 @@ Http::post('/v1/account/verifications/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $project->getAttribute('name')) diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 937380b643..9ec2479749 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -231,7 +231,7 @@ function execute( $validations = GraphQL::getStandardValidationRules(); if (System::getEnv('_APP_GRAPHQL_INTROSPECTION', 'enabled') === 'disabled') { - $validations[] = new DisableIntrospection(); + $validations[] = new DisableIntrospection(DisableIntrospection::ENABLED); } if (System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled') { diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5b82e6c1a3..439692e1dd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -833,75 +833,8 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Get custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSmsTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.getSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSMSTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - $template = [ - 'message' => Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl')->render(), - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_SMS_TEMPLATE); - }); - - -Http::get('/v1/projects/:projectId/templates/email/:type/:locale') +Http::get('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -920,10 +853,12 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1000,74 +935,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); -Http::patch('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Update custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmsTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMSTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->param('message', '', new Text(0), 'Template message') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $message, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['sms.' . $type . '-' . $locale] = [ - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'message' => $message, - 'type' => $type, - 'locale' => $locale, - ]), Response::MODEL_SMS_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') +Http::patch('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Update custom email templates') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -1086,7 +955,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -1094,7 +963,9 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1124,79 +995,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -Http::delete('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Reset custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSmsTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON, - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.deleteSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSMSTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($template['sms.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $template['message'] - ]), Response::MODEL_SMS_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -1216,10 +1016,12 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); $project = $dbForPlatform->getDocument('projects', $projectId); diff --git a/app/controllers/general.php b/app/controllers/general.php index 542effc091..b4f4a5c1d1 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; @@ -1014,11 +1014,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'] ?? []; @@ -1044,7 +1044,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'; @@ -1100,10 +1100,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 bd54a8300b..bba00bede1 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -3,15 +3,17 @@ 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; 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; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; @@ -88,7 +90,7 @@ Http::init() ->inject('request') ->inject('dbForPlatform') ->inject('dbForProject') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('project') ->inject('user') ->inject('session') @@ -97,7 +99,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 +195,7 @@ Http::init() 'name' => $apiKey->getName(), ]); - $queueForAudits->setUser($user); + $auditContext->user = $user; } // For standard keys, update last accessed time @@ -264,7 +266,7 @@ Http::init() API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, default => ACTIVITY_TYPE_KEY_PROJECT, }); - $queueForAudits->setUser($userClone); + $auditContext->user = $userClone; } // Apply permission @@ -486,7 +488,7 @@ Http::init() ->inject('user') ->inject('queueForEvents') ->inject('queueForMessaging') - ->inject('queueForAudits') + ->inject('auditContext') ->inject('queueForDeletes') ->inject('queueForDatabase') ->inject('queueForBuilds') @@ -503,7 +505,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); @@ -596,13 +598,12 @@ Http::init() ->setProject($project) ->setUser($user); - $queueForAudits - ->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 +612,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->user = $userClone; } /* Auto-set projects */ @@ -790,7 +791,8 @@ Http::shutdown() ->inject('project') ->inject('user') ->inject('queueForEvents') - ->inject('queueForAudits') + ->inject('auditContext') + ->inject('publisherForAudits') ->inject('usage') ->inject('publisherForUsage') ->inject('queueForDeletes') @@ -807,7 +809,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(); @@ -902,7 +904,7 @@ Http::shutdown() if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); if (! empty($resource) && $resource !== $pattern) { - $queueForAudits->setResource($resource); + $auditContext->resource = $resource; } } @@ -912,8 +914,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->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: @@ -931,24 +933,21 @@ Http::shutdown() 'name' => 'Guest', ]); - $queueForAudits->setUser($user); + $auditContext->user = $user; } - if (! empty($queueForAudits->getResource()) && ! $queueForAudits->getUser()->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)) { - $queueForAudits->setPayload($responsePayload); + $auditContext->payload = $responsePayload; } - foreach ($queueForEvents->getParams() as $key => $value) { - $queueForAudits->setParam($key, $value); - } - - $queueForAudits->trigger(); + $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext)); } if (! empty($queueForDeletes->getType())) { @@ -972,7 +971,8 @@ Http::shutdown() if ($useCache) { $resource = $resourceType = null; $data = $response->getPayload(); - if (! empty($data['payload'])) { + $statusCode = $response->getStatusCode(); + if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) { $pattern = $route->getLabel('cache.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); diff --git a/app/http.php b/app/http.php index 31fea23c32..b72f3b7f34 100644 --- a/app/http.php +++ b/app/http.php @@ -72,8 +72,6 @@ $swooleAdapter = new Server( container: $container, ); -$container->set('container', fn () => fn () => $swooleAdapter->getContainer()); - $http = $swooleAdapter->getServer(); /** @@ -525,6 +523,7 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files } $requestContainer = $swooleAdapter->getContainer(); + $requestContainer->set('container', fn () => $requestContainer); $requestContainer->set('request', fn () => $request); $requestContainer->set('response', fn () => $response); diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..f654c10121 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,7 +117,9 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; +use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; +use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -135,7 +137,6 @@ use Appwrite\Utopia\Response\Model\TemplateFramework; use Appwrite\Utopia\Response\Model\TemplateFunction; use Appwrite\Utopia\Response\Model\TemplateRuntime; use Appwrite\Utopia\Response\Model\TemplateSite; -use Appwrite\Utopia\Response\Model\TemplateSMS; use Appwrite\Utopia\Response\Model\TemplateVariable; use Appwrite\Utopia\Response\Model\Token; use Appwrite\Utopia\Response\Model\Topic; @@ -190,8 +191,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); -Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); +Response::setModel(new ProviderRepositoryFrameworkList()); +Response::setModel(new ProviderRepositoryRuntimeList()); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); @@ -373,7 +374,6 @@ Response::setModel(new Headers()); Response::setModel(new Specification()); Response::setModel(new Rule()); Response::setModel(new Schedule()); -Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); Response::setModel(new MFAChallenge()); 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..3f6196c460 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -4,9 +4,8 @@ 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; @@ -14,7 +13,6 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Event\Realtime; -use Appwrite\Event\Screenshot; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Functions\EventProcessor; @@ -128,9 +126,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,18 +144,13 @@ 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); - }, ['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 f893c84858..c505d4cb3a 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,8 +1,6 @@ set('queueForScreenshots', function (Publisher $publisher) { - return new Screenshot($publisher); - }, ['publisher']); - $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); @@ -323,10 +316,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,10 +328,6 @@ return function (Container $container): void { return new Realtime(); }, []); - $container->set('queueForCertificates', function (Publisher $publisher) { - return new Certificate($publisher); - }, ['publisher']); - $container->set('deviceForSites', function (Document $project, Telemetry $telemetry) { return new TelemetryDevice($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())); }, ['project', 'telemetry']); diff --git a/app/realtime.php b/app/realtime.php index 955832e93a..5631a7f860 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -398,6 +398,11 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); + $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.delivery_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + )); $attempts = 0; $start = time(); @@ -592,6 +597,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); $stats->incr($event['project'], 'messages', $total); + $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; + if (\is_string($updatedAt)) { + try { + $updatedAtDate = new \DateTimeImmutable($updatedAt); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $delayMs = (int) \max(0, $nowTimestampMs - $updatedAtTimestampMs); + + $register->get('telemetry.deliveryDelayHistogram')->record($delayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } $projectId = $event['project'] ?? null; diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index ef4d4a1fe4..1bf36b7f6d 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -120,7 +120,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_SMTP_HOST - _APP_SMTP_PORT - _APP_SMTP_SECURE @@ -256,7 +255,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_USAGE_STATS - _APP_LOGGING_CONFIG @@ -287,7 +285,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-webhooks: @@ -315,7 +312,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -356,7 +352,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_STORAGE_DEVICE - _APP_STORAGE_S3_ACCESS_KEY - _APP_STORAGE_S3_SECRET @@ -416,7 +411,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-builds: @@ -453,7 +447,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_VCS_GITHUB_APP_NAME - _APP_VCS_GITHUB_PRIVATE_KEY @@ -529,7 +522,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-executions: @@ -592,7 +584,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_FUNCTIONS_TIMEOUT - _APP_SITES_TIMEOUT - _APP_COMPUTE_BUILD_TIMEOUT @@ -630,7 +621,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -673,7 +663,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_SMS_FROM - _APP_SMS_PROVIDER @@ -734,7 +723,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET @@ -773,7 +761,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_MAINTENANCE_INTERVAL - _APP_MAINTENANCE_RETENTION_EXECUTION - _APP_MAINTENANCE_RETENTION_CACHE @@ -806,7 +793,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -839,7 +825,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -871,7 +856,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -907,7 +891,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-executions: image: /: @@ -936,7 +919,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-messages: image: /: @@ -965,7 +947,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-assistant: @@ -1068,13 +1049,12 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging + restart: unless-stopped networks: - appwrite volumes: - appwrite-mongodb:/data/db - appwrite-mongodb-keyfile:/data/keyfile - ports: - - "27017:27017" environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=${_APP_DB_ROOT_PASS} @@ -1205,7 +1185,6 @@ volumes: appwrite-mongodb: appwrite-mongodb-keyfile: - appwrite-mongodb-config: appwrite-redis: appwrite-cache: diff --git a/composer.json b/composer.json index 4ad1ae6120..6312243e32 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.9.*", - "utopia-php/platform": "0.12.*", + "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", "utopia-php/preloader": "0.2.*", @@ -92,7 +92,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "14.11.*", + "webonyx/graphql-php": "15.31.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index 84febe60a8..56b838a0fe 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4fb974e9843f6104e40396e7cad4a833", + "content-hash": "c5ae97637fd0ec0a950044d1c33677ea", "packages": [ { "name": "adhocore/jwt", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.20", + "version": "5.3.21", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24" + "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", - "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", + "url": "https://api.github.com/repos/utopia-php/database/zipball/ee2d7d4c87b3a3fae954089ad7494ceb454f619d", + "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.20" + "source": "https://github.com/utopia-php/database/tree/5.3.21" }, - "time": "2026-04-10T08:27:41+00:00" + "time": "2026-04-10T12:38:57+00:00" }, { "name": "utopia-php/detector", @@ -4325,16 +4325,16 @@ }, { "name": "utopia-php/image", - "version": "0.8.4", + "version": "0.8.5", "source": { "type": "git", "url": "https://github.com/utopia-php/image.git", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65" + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/image/zipball/ce788ff0121a79286fdbe3ef3eba566de646df65", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65", + "url": "https://api.github.com/repos/utopia-php/image/zipball/9af2fcff028a42550465e2ccad88e3b31c3584f3", + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3", "shasum": "" }, "require": { @@ -4343,10 +4343,12 @@ "php": ">=8.1" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "^1.10.0", - "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.13.1" + "laravel/pint": "1.24.*", + "phpstan/phpstan": "2.1.*", + "phpunit/phpunit": "10.5.*" + }, + "suggest": { + "ext-imagick": "Imagick extension is required for Imagick adapter" }, "type": "library", "autoload": { @@ -4368,9 +4370,9 @@ ], "support": { "issues": "https://github.com/utopia-php/image/issues", - "source": "https://github.com/utopia-php/image/tree/0.8.4" + "source": "https://github.com/utopia-php/image/tree/0.8.5" }, - "time": "2025-06-03T08:32:20+00:00" + "time": "2026-04-17T15:02:49+00:00" }, { "name": "utopia-php/locale", @@ -4642,16 +4644,16 @@ }, { "name": "utopia-php/platform", - "version": "0.12.1", + "version": "0.13.0", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/d23af5349a7ea9ee11f9920a13626226f985522e", + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e", "shasum": "" }, "require": { @@ -4687,9 +4689,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.12.1" + "source": "https://github.com/utopia-php/platform/tree/0.13.0" }, - "time": "2026-04-08T04:11:31+00:00" + "time": "2026-04-17T09:57:18+00:00" }, { "name": "utopia-php/pools", @@ -5381,38 +5383,48 @@ }, { "name": "webonyx/graphql-php", - "version": "v14.11.10", + "version": "v15.31.5", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19" + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", + "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "php": "^7.1 || ^8" + "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.3", - "doctrine/coding-standard": "^6.0", - "nyholm/psr7": "^1.2", + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.94.2", + "mll-lab/php-cs-fixer-config": "5.13.0", + "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "0.12.82", - "phpstan/phpstan-phpunit": "0.12.18", - "phpstan/phpstan-strict-rules": "0.12.9", - "phpunit/phpunit": "^7.2 || ^8.5", - "psr/http-message": "^1.0", - "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0" + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.46", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.10", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7 || ^8", + "thecodingmachine/safe": "^1.3 || ^2 || ^3", + "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" }, @@ -5434,15 +5446,19 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10" + "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" }, "funding": [ + { + "url": "https://github.com/spawnia", + "type": "github" + }, { "url": "https://opencollective.com/webonyx-graphql-php", "type": "open_collective" } ], - "time": "2023-07-05T14:23:37+00:00" + "time": "2026-04-11T18:06:15+00:00" } ], "packages-dev": [ @@ -8449,5 +8465,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 42bc0bd7e4..2e53b67901 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1286,6 +1286,7 @@ services: image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging + restart: on-failure:3 networks: - appwrite volumes: diff --git a/docs/references/projects/delete-sms-template.md b/docs/references/projects/delete-sms-template.md deleted file mode 100644 index c5a7e6cac9..0000000000 --- a/docs/references/projects/delete-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Reset a custom SMS template to its default value. This endpoint removes any custom message and restores the template to its original state. \ No newline at end of file diff --git a/docs/references/projects/get-sms-template.md b/docs/references/projects/get-sms-template.md deleted file mode 100644 index 6ef1d93029..0000000000 --- a/docs/references/projects/get-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a custom SMS template for the specified locale and type returning it's contents. \ No newline at end of file diff --git a/docs/references/projects/update-sms-template.md b/docs/references/projects/update-sms-template.md deleted file mode 100644 index 3e67f613b7..0000000000 --- a/docs/references/projects/update-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Update a custom SMS template for the specified locale and type. Use this endpoint to modify the content of your SMS templates. \ No newline at end of file diff --git a/public/images/sites/templates/crm-dashboard-react-admin-dark.png b/public/images/sites/templates/dashboard-react-admin-dark.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-dark.png rename to public/images/sites/templates/dashboard-react-admin-dark.png diff --git a/public/images/sites/templates/crm-dashboard-react-admin-light.png b/public/images/sites/templates/dashboard-react-admin-light.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-light.png rename to public/images/sites/templates/dashboard-react-admin-light.png diff --git a/src/Appwrite/Bus/Listeners/Log.php b/src/Appwrite/Bus/Listeners/Log.php index 076ed5c02d..585d4b09a7 100644 --- a/src/Appwrite/Bus/Listeners/Log.php +++ b/src/Appwrite/Bus/Listeners/Log.php @@ -7,6 +7,8 @@ use Appwrite\Event\Message\Execution as ExecutionMessage; use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Utopia\Bus\Listener; use Utopia\Database\Document; +use Utopia\Span\Span; +use Utopia\System\System; class Log extends Listener { @@ -30,9 +32,27 @@ class Log extends Listener public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void { + $project = new Document($event->project); + $execution = new Document($event->execution); + if ($execution->getAttribute('resourceType', '') === 'functions') { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.v1_executions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + } + $publisherForExecutions->enqueue(new ExecutionMessage( - project: new Document($event->project), - execution: new Document($event->execution), + project: $project, + execution: $execution, )); } } diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 2ffcbc9aa4..3d31101d2b 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -71,7 +71,9 @@ class Mails extends Listener throw new \Exception('Invalid template path'); } - $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? []; + $customTemplate = + $project->getAttribute('templates', [])["email.sessionAlert-" . $locale->default] ?? + $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); diff --git a/src/Appwrite/Event/Context/Audit.php b/src/Appwrite/Event/Context/Audit.php new file mode 100644 index 0000000000..1d41890476 --- /dev/null +++ b/src/Appwrite/Event/Context/Audit.php @@ -0,0 +1,34 @@ +project === null + && $this->user === null + && $this->mode === '' + && $this->userAgent === '' + && $this->ip === '' + && $this->hostname === '' + && $this->event === '' + && $this->resource === '' + && $this->payload === []; + } +} diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index ae75e3924f..fae2d0e843 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -637,9 +637,11 @@ class Event */ $eventValues = \array_values($events); - /** - * Return a combined list of table, collection events and if tablesdb present then include all for backward compatibility - */ + $databaseType = $database?->getAttribute('type', 'legacy'); + if ($database !== null && !\in_array($databaseType, ['legacy', 'tablesdb'], true)) { + return $eventValues; + } + return Event::mirrorCollectionEvents($pattern, $eventValues[0], $eventValues); } @@ -662,21 +664,30 @@ class Event } /** - * Adds `table` events for `collection` events. + * Adds table/collection counterpart events for backward compatibility. * * Example: * * `databases.*.collections.*.documents.*.update` →\ * `[databases.*.collections.*.documents.*.update, databases.*.tables.*.rows.*.update]` + * + * `databases.*.tables.*.rows.*.update` →\ + * `[databases.*.tables.*.rows.*.update, databases.*.collections.*.documents.*.update]` */ private static function mirrorCollectionEvents(string $pattern, string $firstEvent, array $events): array { - $tableEventMap = [ + $collectionsToTablesMap = [ 'documents' => 'rows', 'collections' => 'tables', 'attributes' => 'columns', ]; + $tablesToCollectionsMap = [ + 'rows' => 'documents', + 'tables' => 'collections', + 'columns' => 'attributes', + ]; + $databasesEventMap = [ 'tablesdb' => 'databases', 'tables' => 'collections', @@ -687,7 +698,10 @@ class Event if ( ( str_contains($pattern, 'databases.') && - str_contains($firstEvent, 'collections') + ( + str_contains($firstEvent, 'collections') || + str_contains($firstEvent, 'tables') + ) ) || ( str_contains($firstEvent, 'tablesdb.') @@ -698,25 +712,16 @@ class Event $pairedEvents[] = $event; // tablesdb needs databases event with tables and collections if (str_contains($event, 'tablesdb')) { - $databasesSideEvent = str_replace( - array_keys($databasesEventMap), - array_values($databasesEventMap), - $event - ); + $databasesSideEvent = self::replaceEventSegments($event, $databasesEventMap); $pairedEvents[] = $databasesSideEvent; - $tableSideEvent = str_replace( - array_keys($tableEventMap), - array_values($tableEventMap), - $databasesSideEvent - ); + $tableSideEvent = self::replaceEventSegments($databasesSideEvent, $collectionsToTablesMap); $pairedEvents[] = $tableSideEvent; } elseif (str_contains($event, 'collections')) { - $tableSideEvent = str_replace( - array_keys($tableEventMap), - array_values($tableEventMap), - $event - ); + $tableSideEvent = self::replaceEventSegments($event, $collectionsToTablesMap); $pairedEvents[] = $tableSideEvent; + } elseif (str_contains($event, 'tables')) { + $collectionSideEvent = self::replaceEventSegments($event, $tablesToCollectionsMap); + $pairedEvents[] = $collectionSideEvent; } } @@ -728,6 +733,20 @@ class Event return array_values(array_unique($events)); } + /** + * Replace only exact event path segments, never partial substrings. + */ + private static function replaceEventSegments(string $event, array $map): string + { + $parts = \explode('.', $event); + $parts = \array_map( + fn (string $part) => $map[$part] ?? $part, + $parts + ); + + return \implode('.', $parts); + } + /** * Maps event terminology based on database type */ diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php new file mode 100644 index 0000000000..ae5831c3b9 --- /dev/null +++ b/src/Appwrite/Event/Message/Audit.php @@ -0,0 +1,71 @@ + [ + '$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( + event: $data['event'] ?? '', + payload: $data['payload'] ?? [], + project: new Document($data['project'] ?? []), + user: new Document($data['user'] ?? []), + resource: $data['resource'] ?? '', + mode: $data['mode'] ?? '', + ip: $data['ip'] ?? '', + userAgent: $data['userAgent'] ?? '', + 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, + ); + } +} 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..a06cdfbfc0 --- /dev/null +++ b/src/Appwrite/Event/Message/Screenshot.php @@ -0,0 +1,34 @@ + [ + '$id' => $this->project->getId(), + '$sequence' => $this->project->getSequence(), + 'database' => $this->project->getAttribute('database', ''), + ], + 'deploymentId' => $this->deploymentId, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + deploymentId: $data['deploymentId'] ?? '', + ); + } +} 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/GraphQL/Promises/Adapter.php b/src/Appwrite/GraphQL/Promises/Adapter.php index 86270f2a8b..1d9cc4557f 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter.php +++ b/src/Appwrite/GraphQL/Promises/Adapter.php @@ -81,8 +81,8 @@ abstract class Adapter implements PromiseAdapter /** * Create a new promise that resolves when all passed in promises resolve. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return GQLPromise */ - abstract public function all(array $promisesOrValues): GQLPromise; + abstract public function all(iterable $promisesOrValues): GQLPromise; } diff --git a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php index efe6eb2f50..af6441ad6d 100644 --- a/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php +++ b/src/Appwrite/GraphQL/Promises/Adapter/Swoole.php @@ -35,8 +35,12 @@ class Swoole extends Adapter return new GQLPromise($promise, $this); } - public function all(array $promisesOrValues): GQLPromise + public function all(iterable $promisesOrValues): GQLPromise { + if ($promisesOrValues instanceof \Traversable) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + return new GQLPromise(SwoolePromise::all($promisesOrValues), $this); } } diff --git a/src/Appwrite/GraphQL/ResolverLock.php b/src/Appwrite/GraphQL/ResolverLock.php new file mode 100644 index 0000000000..b1cdcf3d53 --- /dev/null +++ b/src/Appwrite/GraphQL/ResolverLock.php @@ -0,0 +1,55 @@ +channel = new Channel(1); + } + + /** + * Acquire the lock. Re-entering from the same coroutine only + * increments depth to avoid self-deadlock. + */ + public function acquire(): void + { + $cid = Coroutine::getCid(); + + if ($this->owner === $cid) { + $this->depth++; + return; + } + + $this->channel->push(true); + $this->owner = $cid; + $this->depth = 1; + } + + /** + * Release the lock. + */ + public function release(): void + { + if ($this->owner !== Coroutine::getCid()) { + return; + } + + $this->depth--; + + if ($this->depth > 0) { + return; + } + + $this->owner = null; + $this->channel->pop(); + } +} diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 65f8a64d68..cabb357607 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -6,6 +6,7 @@ use Appwrite\GraphQL\Exception as GQLException; use Appwrite\Promises\Swoole; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; +use Utopia\DI\Container; use Utopia\Http\Exception; use Utopia\Http\Http; use Utopia\Http\Route; @@ -13,6 +14,75 @@ use Utopia\System\System; class Resolvers { + /** + * Request-scoped locks keyed by the per-request GraphQL Http instance. + * + * @var array + */ + private static array $locks = []; + + /** + * Preserve response side effects that callers depend on, such as session + * cookies created by account auth routes. + */ + private static function mergeResponseSideEffects(Response $from, Response $to): void + { + foreach ($from->getCookies() as $cookie) { + $to->removeCookie($cookie['name']); + $to->addCookie( + $cookie['name'], + $cookie['value'], + $cookie['expire'], + $cookie['path'], + $cookie['domain'], + $cookie['secure'], + $cookie['httponly'], + $cookie['samesite'] + ); + } + + $headers = $from->getHeaders(); + $fallbackCookies = $headers['X-Fallback-Cookies'] ?? null; + if ($fallbackCookies === null) { + return; + } + + $to->removeHeader('X-Fallback-Cookies'); + foreach ((array) $fallbackCookies as $value) { + $to->addHeader('X-Fallback-Cookies', $value); + } + } + + /** + * Get the current request container. + */ + private static function getResolverContainer(Http $utopia): Container + { + $container = $utopia->getResource('container'); + + if ($container instanceof Container || (\is_object($container) && \method_exists($container, 'get') && \method_exists($container, 'set'))) { + /** @var Container $container */ + return $container; + } + + /** @var callable(): Container $container */ + return $container(); + } + + /** + * Get the request-scoped lock shared by GraphQL resolver coroutines + * for the current HTTP request. + */ + private static function getLock(Http $utopia): ResolverLock + { + $key = \spl_object_hash($utopia); + if (!isset(self::$locks[$key])) { + self::$locks[$key] = new ResolverLock(); + } + + return self::$locks[$key]; + } + /** * Create a resolver for a given API {@see Route}. * @@ -24,34 +94,39 @@ class Resolvers Http $utopia, ?Route $route, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $route, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $path = $route->getPath(); - foreach ($args as $key => $value) { - if (\str_contains($path, '/:' . $key)) { - $path = \str_replace(':' . $key, $value, $path); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($route, $args): void { + $path = $route->getPath(); + foreach ($args as $key => $value) { + if (\str_contains($path, '/:' . $key)) { + $path = \str_replace(':' . $key, $value, $path); + } + } + + $request->setMethod($route->getMethod()); + $request->setURI($path); + + switch ($route->getMethod()) { + case 'GET': + $request->setQueryString($args); + break; + default: + $request->setPayload($args); + break; } } - - $request->setMethod($route->getMethod()); - $request->setURI($path); - - switch ($route->getMethod()) { - case 'GET': - $request->setQueryString($args); - break; - default: - $request->setPayload($args); - break; - } - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + ); + }); } /** @@ -91,18 +166,23 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -122,23 +202,29 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('GET'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setQueryString($params($databaseId, $collectionId, $args)); + $beforeResolve = function ($payload) { + return $payload['documents']; + }; - $beforeResolve = function ($payload) { - return $payload['documents']; - }; - - self::resolve($utopia, $request, $response, $resolve, $reject, $beforeResolve); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + beforeResolve: $beforeResolve, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('GET'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setQueryString($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -158,19 +244,24 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('POST'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('POST'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -190,19 +281,24 @@ class Resolvers callable $url, callable $params, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('PATCH'); - $request->setURI($url($databaseId, $collectionId, $args)); - $request->setPayload($params($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $params, $args): void { + $request->setMethod('PATCH'); + $request->setURI($url($databaseId, $collectionId, $args)); + $request->setPayload($params($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -220,18 +316,23 @@ class Resolvers string $collectionId, callable $url, ): callable { - return static fn ($type, $args, $context, $info) => new Swoole( - function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { - $utopia = $utopia->getResource('utopia:graphql'); - $request = $utopia->getResource('request'); - $response = $utopia->getResource('response'); + return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) { + $utopia = $utopia->getResource('utopia:graphql'); + $request = $utopia->getResource('request'); + $response = $utopia->getResource('response'); - $request->setMethod('DELETE'); - $request->setURI($url($databaseId, $collectionId, $args)); - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); + self::resolve( + $utopia, + $request, + $response, + $resolve, + $reject, + prepareRequest: static function (Request $request) use ($databaseId, $collectionId, $url, $args): void { + $request->setMethod('DELETE'); + $request->setURI($url($databaseId, $collectionId, $args)); + } + ); + }); } /** @@ -241,7 +342,7 @@ class Resolvers * @param callable $resolve * @param callable $reject * @param callable|null $beforeResolve - * @param callable|null $beforeReject + * @param callable|null $prepareRequest * @return void * @throws Exception */ @@ -252,39 +353,67 @@ class Resolvers callable $resolve, callable $reject, ?callable $beforeResolve = null, - ?callable $beforeReject = null, + ?callable $prepareRequest = null, ): void { - // Drop json content type so post args are used directly - if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { - $request->removeHeader('content-type'); - } + $lock = self::getLock($utopia); - $request = clone $request; - $utopia->setResource('request', static fn () => $request); - $response->setContentType(Response::CONTENT_TYPE_NULL); - $response->clearSent(); + $lock->acquire(); + $original = $utopia->getRoute(); try { - $route = $utopia->match($request, fresh: true); + $request = clone $request; - $utopia->execute($route, $request, $response); - } catch (\Throwable $e) { - if ($beforeReject) { - $e = $beforeReject($e); + // Drop json content type so post args are used directly. + if (\str_starts_with($request->getHeader('content-type'), 'application/json')) { + $request->removeHeader('content-type'); } + + if ($prepareRequest) { + $prepareRequest($request); + } + + /** @var Response $resolverResponse */ + $resolverResponse = clone $utopia->getResource('response'); + $container = self::getResolverContainer($utopia); + $container->set('request', static fn () => $request); + $container->set('response', static fn () => $resolverResponse); + $resolverResponse->setContentType(Response::CONTENT_TYPE_NULL); + $resolverResponse->setSent(false); + + $route = $utopia->match($request, fresh: true); + $request->setRoute($route); + + $utopia->execute($route, $request, $resolverResponse); + + self::mergeResponseSideEffects($resolverResponse, $response); + + if ($resolverResponse->isSent()) { + $response + ->setStatusCode($resolverResponse->getStatusCode()) + ->setSent(true); + + $resolve(null); + return; + } + + $payload = $resolverResponse->getPayload(); + $statusCode = $resolverResponse->getStatusCode(); + } catch (\Throwable $e) { $reject($e); return; + } finally { + if ($original !== null) { + $utopia->setRoute($original); + } + + $lock->release(); + unset(self::$locks[\spl_object_hash($utopia)]); } - $payload = $response->getPayload(); - - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) { - if ($beforeReject) { - $payload = $beforeReject($payload); - } + if ($statusCode < 200 || $statusCode >= 400) { $reject(new GQLException( message: $payload['message'], - code: $response->getStatusCode() + code: $statusCode )); return; } diff --git a/src/Appwrite/GraphQL/Types/Assoc.php b/src/Appwrite/GraphQL/Types/Assoc.php index f76b23dd7a..15bd742d1d 100644 --- a/src/Appwrite/GraphQL/Types/Assoc.php +++ b/src/Appwrite/GraphQL/Types/Assoc.php @@ -3,12 +3,13 @@ namespace Appwrite\GraphQL\Types; use GraphQL\Language\AST\Node; +use GraphQL\Language\AST\StringValueNode; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Assoc extends Json { - public $name = 'Assoc'; - public $description = 'The `Assoc` scalar type represents associative array values.'; + public string $name = 'Assoc'; + public ?string $description = 'The `Assoc` scalar type represents associative array values.'; public function serialize($value) { @@ -30,6 +31,10 @@ class Assoc extends Json public function parseLiteral(Node $valueNode, ?array $variables = null) { - return \json_decode($valueNode->value, true); + if ($valueNode instanceof StringValueNode) { + return \json_decode($valueNode->value, true); + } + + return parent::parseLiteral($valueNode, $variables); } } diff --git a/src/Appwrite/GraphQL/Types/InputFile.php b/src/Appwrite/GraphQL/Types/InputFile.php index 39fd4e23b3..daa771911b 100644 --- a/src/Appwrite/GraphQL/Types/InputFile.php +++ b/src/Appwrite/GraphQL/Types/InputFile.php @@ -8,8 +8,8 @@ use GraphQL\Type\Definition\ScalarType; class InputFile extends ScalarType { - public $name = 'InputFile'; - public $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by + public string $name = 'InputFile'; + public ?string $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).'; public function serialize($value) diff --git a/src/Appwrite/GraphQL/Types/Json.php b/src/Appwrite/GraphQL/Types/Json.php index 18d27322a1..627b9081d3 100644 --- a/src/Appwrite/GraphQL/Types/Json.php +++ b/src/Appwrite/GraphQL/Types/Json.php @@ -14,8 +14,8 @@ use GraphQL\Type\Definition\ScalarType; // https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803 class Json extends ScalarType { - public $name = 'Json'; - public $description = 'The `JSON` scalar type represents JSON values as specified by + public string $name = 'Json'; + public ?string $description = 'The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).'; public function serialize($value) diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 20a6afed2e..14dc4e3237 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -170,11 +170,6 @@ class Create extends Action $message = Template::fromFile($templatesPath . '/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $projectName) @@ -223,7 +218,9 @@ class Create extends Action $preview = $locale->getText("emails.mfaChallenge.preview"); $heading = $locale->getText("emails.mfaChallenge.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 38c84c4ae1..24cba578a9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -50,6 +50,11 @@ class Create extends Action return UtopiaResponse::MODEL_DOCUMENT_LIST; } + protected function getSupportForEmptyDocument() + { + return false; + } + public function __construct() { $this @@ -139,30 +144,42 @@ class Create extends Action ->inject('eventProcessor') ->callback($this->action(...)); } + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) : $data; + $supportsEmptyDocument = $this->getSupportForEmptyDocument(); + $hasData = !empty($data); + $hasDocuments = !empty($documents); + /** * Determine which internal path to call, single or bulk */ - if (empty($data) && empty($documents)) { + if (!$supportsEmptyDocument && !$hasData && !$hasDocuments) { // No single or bulk documents provided throw new Exception($this->getMissingDataException()); } - if (!empty($data) && !empty($documents)) { + + // When empty documents are supported, an empty payload should still be treated as single create. + if ($supportsEmptyDocument && !$hasData && !$hasDocuments) { + $data = []; + $hasData = true; + } + + if ($hasData && $hasDocuments) { // Both single and bulk documents provided throw new Exception(Exception::GENERAL_BAD_REQUEST, 'You can only send one of the following parameters: data, ' . $this->getSDKGroup()); } - if (!empty($data) && empty($documentId)) { + if ($hasData && empty($documentId)) { // Single document provided without document ID $document = $this->isCollectionsAPI() ? 'Document' : 'Row'; $message = "$document ID is required when creating a single " . strtolower($document) . '.'; throw new Exception($this->getMissingDataException(), $message); } - if (!empty($documents) && !empty($documentId)) { + if ($hasDocuments && !empty($documentId)) { // Bulk documents provided with document ID $documentId = $this->isCollectionsAPI() ? 'documentId' : 'rowId'; throw new Exception( @@ -170,13 +187,13 @@ class Create extends Action "Param \"$documentId\" is not allowed when creating multiple " . $this->getSDKGroup() . ', set "$id" on each instead.' ); } - if (!empty($documents) && !empty($permissions)) { + if ($hasDocuments && !empty($permissions)) { // Bulk documents provided with permissions throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "permissions" is disallowed when creating multiple ' . $this->getSDKGroup() . ', set "$permissions" on each instead'); } - $isBulk = true; - if (!empty($data)) { + $isBulk = $hasDocuments; + if ($hasData) { // Single document provided, convert to single item array // But remember that it was single to respond with a single document $isBulk = false; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php index 039a05ff50..532ae826e2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php @@ -34,6 +34,12 @@ class Create extends DocumentCreate return UtopiaResponse::MODEL_DOCUMENT_LIST; } + protected function getSupportForEmptyDocument() + { + return true; + } + + public function __construct() { $this diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php deleted file mode 100644 index cc7fe41555..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php +++ /dev/null @@ -1,59 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') - ->desc('List document logs') - ->groups(['api', 'database']) - ->label('scope', 'documents.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'documentsDB', - group: 'logs', - name: 'listDocumentLogs', - description: '/docs/references/documentsdb/get-document-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON, - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('documentId', '', new UID(), 'Document ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php deleted file mode 100644 index 51695ea165..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php +++ /dev/null @@ -1,58 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/logs') - ->desc('List collection logs') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'documentsDB', - group: $this->getSdkGroup(), - name: 'listCollectionLogs', - description: '/docs/references/documentsdb/get-collection-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) - ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php deleted file mode 100644 index dea9d30119..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php +++ /dev/null @@ -1,59 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') - ->desc('List document logs') - ->groups(['api', 'database']) - ->label('scope', 'documents.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'vectorsDB', - group: 'logs', - name: 'listDocumentLogs', - description: '/docs/references/vectorsdb/get-document-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON, - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('documentId', '', new UID(), 'Document ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('getDatabasesDB') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php deleted file mode 100644 index cd0e45eb47..0000000000 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php +++ /dev/null @@ -1,57 +0,0 @@ -setHttpMethod(self::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/logs') - ->desc('List collection logs') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'vectorsDB', - group: $this->getSdkGroup(), - name: 'listCollectionLogs', - description: '/docs/references/vectorsdb/get-collection-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: SwooleResponse::STATUS_CODE_OK, - model: $this->getResponseModel(), - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('collectionId', '', new UID(), 'Collection ID.') - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('locale') - ->inject('geodb') - ->inject('authorization') - ->inject('audit') - ->callback($this->action(...)); - } -} diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php index a1e3538cac..5d41ed3e2b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php @@ -12,7 +12,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\B use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Create as CreateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Delete as DeleteRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Get as GetRow; -use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Logs\XList as ListRowLogs; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Update as UpdateRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\Upsert as UpsertRow; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Documents\XList as ListRows; @@ -21,7 +20,6 @@ use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Cre use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Delete as DeleteColumnIndex; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\Get as GetColumnIndex; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Indexes\XList as ListColumnIndexes; -use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Logs\XList as ListTableLogs; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Update as UpdateTable; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\Usage\Get as GetTableUsage; use Appwrite\Platform\Modules\Databases\Http\DocumentsDB\Collections\XList as ListTables; @@ -69,7 +67,6 @@ class DocumentsDB extends Base $service->addAction(UpdateTable::getName(), new UpdateTable()); $service->addAction(DeleteTable::getName(), new DeleteTable()); $service->addAction(ListTables::getName(), new ListTables()); - $service->addAction(ListTableLogs::getName(), new ListTableLogs()); $service->addAction(GetTableUsage::getName(), new GetTableUsage()); } @@ -92,7 +89,6 @@ class DocumentsDB extends Base $service->addAction(DeleteRow::getName(), new DeleteRow()); $service->addAction(DeleteRows::getName(), new DeleteRows()); $service->addAction(ListRows::getName(), new ListRows()); - $service->addAction(ListRowLogs::getName(), new ListRowLogs()); $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn()); $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn()); } diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php index 5d12b14b1a..fe96d51d20 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php @@ -10,7 +10,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Bul use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Create as CreateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Delete as DeleteDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Get as GetDocument; -use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Logs\XList as ListDocumentLogs; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Update as UpdateDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\Upsert as UpsertDocument; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Documents\XList as ListDocuments; @@ -19,7 +18,6 @@ use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Creat use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Delete as DeleteIndex; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\Get as GetIndex; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Indexes\XList as ListIndexes; -use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Logs\XList as ListCollectionLogs; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Update as UpdateCollection; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\Usage\Get as GetCollectionUsage; use Appwrite\Platform\Modules\Databases\Http\VectorsDB\Collections\XList as ListCollections; @@ -69,7 +67,6 @@ class VectorsDB extends Base $service->addAction(UpdateCollection::getName(), new UpdateCollection()); $service->addAction(DeleteCollection::getName(), new DeleteCollection()); $service->addAction(ListCollections::getName(), new ListCollections()); - $service->addAction(ListCollectionLogs::getName(), new ListCollectionLogs()); $service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage()); } @@ -92,7 +89,6 @@ class VectorsDB extends Base $service->addAction(UpdateDocuments::getName(), new UpdateDocuments()); $service->addAction(UpsertDocuments::getName(), new UpsertDocuments()); $service->addAction(DeleteDocuments::getName(), new DeleteDocuments()); - $service->addAction(ListDocumentLogs::getName(), new ListDocumentLogs()); } private function registerTransactionActions(Service $service): void diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php index 50c901e4c8..d3e7155dc6 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php @@ -31,7 +31,7 @@ class Get extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/download') - ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download', ['type' => 'output']) + ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download') ->groups(['api', 'functions']) ->desc('Get deployment download') ->label('scope', 'functions.read') diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index c6c4a0b38c..87e936a965 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, @@ -144,7 +144,8 @@ class Builds extends Action $log, $executor, $plan, - $platform + $platform, + (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -161,7 +162,7 @@ class Builds extends Action Device $deviceForFunctions, Device $deviceForSites, Device $deviceForFiles, - Screenshot $queueForScreenshots, + Screenshot $publisherForScreenshots, Webhook $queueForWebhooks, Func $queueForFunctions, Realtime $queueForRealtime, @@ -179,7 +180,8 @@ class Builds extends Action Log $log, Executor $executor, array $plan, - array $platform + array $platform, + int $timeout ): void { Console::info('Deployment action started'); @@ -592,10 +594,7 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); - - $jwtExpiry = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), @@ -1120,10 +1119,10 @@ 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(), + )); 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/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php similarity index 80% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php index 1fa2df3566..ad5691c1e0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php @@ -1,7 +1,8 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->setHttpPath('/v1/project/protocols/:protocolId') + ->httpAlias('/v1/project/protocols/:protocolId/status') ->httpAlias('/v1/projects/:projectId/api') - ->desc('Update project protocol status') + ->desc('Update project protocol') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'protocols.[protocol].update') - ->label('audits.event', 'project.protocols.[protocol].update') + ->label('event', 'protocols.[protocolId].update') + ->label('audits.event', 'project.protocols.[protocolId].update') ->label('audits.resource', 'project.protocols/{response.$id}') ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateProtocolStatus', + name: 'updateProtocol', description: <<inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,7 +69,8 @@ class Update extends Action Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + Event $queueForEvents, ): void { $protocols = $project->getAttribute('apis', []); $protocols[$protocolId] = $enabled; @@ -75,6 +79,8 @@ class Update extends Action 'apis' => $protocols, ]))); + $queueForEvents->setParam('protocolId', $protocolId); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php similarity index 79% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php index 35be32a604..7aab6f5ad0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Update.php @@ -1,7 +1,8 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/services/:serviceId/status') + ->setHttpPath('/v1/project/services/:serviceId') + ->httpAlias('/v1/project/services/:serviceId/status') ->httpAlias('/v1/projects/:projectId/service') - ->desc('Update project service status') + ->desc('Update project service') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'services.[service].update') - ->label('audits.event', 'project.services.[service].update') + ->label('event', 'services.[serviceId].update') + ->label('audits.event', 'project.services.[serviceId].update') ->label('audits.resource', 'project.services/{response.$id}') ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateServiceStatus', + name: 'updateService', description: <<inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,7 +69,8 @@ class Update extends Action Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + Event $queueForEvents ): void { $services = $project->getAttribute('services', []); $services[$serviceId] = $enabled; @@ -75,6 +79,8 @@ class Update extends Action 'services' => $services, ]))); + $queueForEvents->setParam('serviceId', $serviceId); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index a2c94928e2..bcab75a8c5 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,8 +22,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; -use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; -use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -42,8 +42,8 @@ class Http extends Service // Project $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); - $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); - $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); + $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); + $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); 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/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f0ee045214..f6b6eb25da 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -54,7 +54,7 @@ class Get extends Action ->label('cache', true) ->label('cache.resourceType', 'bucket/{request.bucketId}') ->label('cache.resource', 'file/{request.fileId}') - ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output']) + ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output', 'project']) ->label('sdk', new Method( namespace: 'storage', group: 'files', diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5edc69f445..aa4ee2c66c 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -324,7 +324,9 @@ class Create extends Action $body = $locale->getText('emails.invitation.body'); $preview = $locale->getText('emails.invitation.preview'); $subject = $locale->getText('emails.invitation.subject'); - $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.invitation-' . $locale->fallback] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message @@ -407,11 +409,6 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? []; - if (! empty($customTemplate)) { - $message = $customTemplate['message']; - } - $message = $message->setParam('{{token}}', $url); $message = $message->render(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 3b516c2d60..d055ecb23f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,6 +126,7 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 5dd5c6dcfa..6295fcd03b 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -307,6 +307,7 @@ class Create extends Action ]; } + $output->setAttribute('type', $type); $output->setAttribute('variables', $variables); $response->dynamic($output, $type === 'framework' ? Response::MODEL_DETECTION_FRAMEWORK : Response::MODEL_DETECTION_RUNTIME); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index d5b2b48175..b4172fabdf 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,6 +313,7 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ + 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); 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/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 4725f4095f..526ea304de 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -622,29 +622,28 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $repo->execute('config', 'advice.defaultBranchName', 'false'); $repo->addRemote('origin', $gitUrl); - // Fetch and checkout base branch (or create if new repo) + // Fetch and checkout the target branch (e.g. dev) if it exists on remote, + // otherwise create it from the base branch (e.g. main). + // We build on top of the existing remote branch so a regular push + // works without force-pushing against protected branches. + $hasBranch = false; try { - $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch); + $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $gitBranch); + $hasBranch = true; + } catch (\Throwable) { + // Branch doesn't exist on remote yet + } + + if ($hasBranch) { + $repo->execute('checkout', '-f', $gitBranch); + } else { + // Fetch base branch to create the target branch from it try { + $repo->execute('fetch', 'origin', '--quiet', '--no-tags', '--depth', '1', $repoBranch); $repo->execute('checkout', '-f', $repoBranch); } catch (\Throwable) { $repo->execute('checkout', '-b', $repoBranch); } - } catch (\Throwable) { - $repo->execute('checkout', '-b', $repoBranch); - } - - try { - $repo->execute('pull', 'origin', $repoBranch, '--quiet', '--no-tags'); - } catch (\Throwable) { - } - - // Create or checkout dev branch from the base branch - // This ensures dev always starts from the latest base branch, - // avoiding history divergence caused by squash merges. - try { - $repo->execute('checkout', '-B', $gitBranch, $repoBranch); - } catch (\Throwable) { $repo->execute('checkout', '-b', $gitBranch); } @@ -685,7 +684,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND return true; } - $repo->execute('push', '--force-with-lease', '-u', 'origin', $gitBranch, '--quiet'); + $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); } catch (\Throwable $e) { Console::warning(" Git push failed: " . $e->getMessage()); return false; 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/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 88725a190a..f867884801 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -7,6 +7,8 @@ use Cron\CronExpression; use Utopia\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; +use Utopia\Span\Span; +use Utopia\System\System; /** * ScheduleFunctions @@ -88,7 +90,7 @@ class ScheduleFunctions extends ScheduleBase $scheduleKey = $delayConfig['key']; // Ensure schedule was not deleted if (!\array_key_exists($scheduleKey, $this->schedules)) { - return; + continue; } $schedule = $this->schedules[$scheduleKey]; @@ -102,8 +104,22 @@ class ScheduleFunctions extends ScheduleBase ->setFunction($schedule['resource']) ->setMethod('POST') ->setPath('/') - ->setProject($schedule['project']) - ->trigger(); + ->setProject($schedule['project']); + + $projectDoc = $schedule['project']; + $functionDoc = $schedule['resource']; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $projectDoc->getId() === $traceProjectId && $functionDoc->getId() === $traceFunctionId) { + Span::init('execution.trace.v1_functions_enqueue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $projectDoc->getId()); + Span::add('functionId', $functionDoc->getId()); + Span::add('scheduleId', $schedule['$id'] ?? ''); + Span::current()?->finish(); + } + + $queueForFunctions->trigger(); $this->recordEnqueueDelay($delayConfig['nextDate']); } 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.'); } diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 673e9de791..99e20be035 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -7,6 +7,8 @@ use Exception; use Utopia\Database\Database; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; +use Utopia\System\System; class Executions extends Action { @@ -39,6 +41,20 @@ class Executions extends Action throw new Exception('Missing execution'); } + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + $resourceId = $execution->getAttribute('resourceId', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $executionMessage->project->getId() === $traceProjectId && $resourceId === $traceFunctionId) { + Span::init('execution.trace.executions_worker_upsert'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $executionMessage->project->getId()); + Span::add('functionId', $resourceId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('resourceType', $execution->getAttribute('resourceType', '')); + Span::current()?->finish(); + } + $dbForProject->upsertDocument('executions', $execution); } } diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index bed28dad1c..0899fbacb4 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -23,6 +23,7 @@ use Utopia\Database\Query; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Span\Span; use Utopia\System\System; class Functions extends Action @@ -115,6 +116,22 @@ class Functions extends Action $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); + if (empty($events) && !$function->isEmpty()) { + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_dequeue'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('payloadType', $type); + Span::add('queuePid', $message->getPid()); + Span::add('queueName', $message->getQueue()); + Span::add('messageTimestamp', (string) $message->getTimestamp()); + Span::current()?->finish(); + } + } + if (!empty($events)) { $limit = 100; $sum = 100; @@ -304,6 +321,20 @@ class Functions extends Action 'duration' => 0.0, ]); + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $function->getId() === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus_fail'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $function->getId()); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('trigger', $trigger); + Span::add('status', $execution->getAttribute('status', '')); + Span::current()?->finish(); + } + $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), @@ -522,6 +553,18 @@ class Functions extends Action $source = $deployment->getAttribute('buildPath', ''); $extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz'; $command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\""; + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_executor'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $executionId); + Span::add('deploymentId', $deployment->getId()); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $executionResponse = $executor->createExecution( projectId: $project->getId(), deploymentId: $deploymentId, @@ -594,6 +637,19 @@ class Functions extends Action $errorCode = $th->getCode(); } finally { /** Persist final execution status and record usage */ + $traceProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', ''); + $traceFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', ''); + if ($traceProjectId !== '' && $traceFunctionId !== '' && $project->getId() === $traceProjectId && $functionId === $traceFunctionId) { + Span::init('execution.trace.functions_worker_before_execution_completed_bus'); + Span::add('datetime', gmdate('c')); + Span::add('projectId', $project->getId()); + Span::add('functionId', $functionId); + Span::add('executionId', $execution->getId()); + Span::add('deploymentId', $execution->getAttribute('deploymentId', '')); + Span::add('status', $execution->getAttribute('status', '')); + Span::add('trigger', $trigger); + Span::current()?->finish(); + } $bus->dispatch(new ExecutionCompleted( execution: $execution->getArrayCopy(), project: $project->getArrayCopy(), diff --git a/src/Appwrite/Promises/Promise.php b/src/Appwrite/Promises/Promise.php index a58c7c29a8..579969cd7b 100644 --- a/src/Appwrite/Promises/Promise.php +++ b/src/Appwrite/Promises/Promise.php @@ -19,8 +19,7 @@ abstract class Promise return; } $resolve = function ($value) { - $this->setResult($value); - $this->setState(self::STATE_FULFILLED); + $this->setState($this->setResult($value)); }; $reject = function ($value) { $this->setResult($value); @@ -106,6 +105,11 @@ abstract class Promise } $callable = $this->isFulfilled() ? $onFulfilled : $onRejected; if (!\is_callable($callable)) { + if ($this->isRejected()) { + $reject($this->result); + return; + } + $resolve($this->result); return; } @@ -126,30 +130,36 @@ abstract class Promise abstract public static function all(iterable $promises): self; /** - * Set resolved result + * Set the resolved result, adopting nested promises while preserving + * whether the adopted promise fulfilled or rejected. * * @param mixed $value - * @return void + * @return int */ - protected function setResult(mixed $value): void + protected function setResult(mixed $value): int { if (!\is_callable([$value, 'then'])) { $this->result = $value; - return; + return self::STATE_FULFILLED; } - $resolved = false; + $state = self::STATE_PENDING; - $callable = function ($value) use (&$resolved) { - $this->setResult($value); - $resolved = true; - }; + $value->then( + function ($value) use (&$state) { + $state = $this->setResult($value); + }, + function ($value) use (&$state) { + $this->result = $value; + $state = self::STATE_REJECTED; + } + ); - $value->then($callable, $callable); - - while (!$resolved) { + while ($state === self::STATE_PENDING) { usleep(25000); } + + return $state; } /** diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 91b090a9f6..e68e9438ca 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,6 +263,182 @@ abstract class Format return $contents; } + /** + * @param array $models + * @return array|null + */ + protected function getDiscriminator(array $models, string $refPrefix): ?array + { + if (\count($models) < 2) { + return null; + } + + $candidateKeys = \array_keys($models[0]->conditions); + + foreach (\array_slice($models, 1) as $model) { + $candidateKeys = \array_values(\array_intersect($candidateKeys, \array_keys($model->conditions))); + } + + if (empty($candidateKeys)) { + return null; + } + + foreach ($candidateKeys as $key) { + $mapping = []; + $isValid = true; + + foreach ($models as $model) { + $rules = $model->getRules(); + $condition = $model->conditions[$key] ?? null; + + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + $isValid = false; + break; + } + + if (!\is_array($condition)) { + if (!\is_scalar($condition)) { + $isValid = false; + break; + } + + $values = [$condition]; + } else { + if ($condition === []) { + $isValid = false; + break; + } + + $values = $condition; + $hasInvalidValue = false; + + foreach ($values as $value) { + if (!\is_scalar($value)) { + $hasInvalidValue = true; + break; + } + } + + if ($hasInvalidValue) { + $isValid = false; + break; + } + } + + if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { + $values = \array_values(\array_filter( + $values, + fn (mixed $value) => \in_array($value, $rules[$key]['enum'], true) + )); + } + + if ($values === []) { + $isValid = false; + break; + } + + $ref = $refPrefix . $model->getType(); + + foreach ($values as $value) { + $mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + + if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $ref) { + $isValid = false; + break; + } + + $mapping[$mappingKey] = $ref; + } + + if (!$isValid) { + break; + } + } + + if (!$isValid || $mapping === []) { + continue; + } + + return [ + 'propertyName' => $key, + 'mapping' => $mapping, + ]; + } + + // Single-key failed — try compound discriminator + return $this->getCompoundDiscriminator($models, $refPrefix); + } + + /** + * @param array $models + * @return array|null + */ + private function getCompoundDiscriminator(array $models, string $refPrefix): ?array + { + $allKeys = []; + foreach ($models as $model) { + foreach (\array_keys($model->conditions) as $key) { + if (!\in_array($key, $allKeys, true)) { + $allKeys[] = $key; + } + } + } + + if (\count($allKeys) < 2) { + return null; + } + + $primaryKey = $allKeys[0]; + $primaryMapping = []; + $compoundMapping = []; + + foreach ($models as $model) { + $rules = $model->getRules(); + $conditions = []; + + foreach ($model->conditions as $key => $condition) { + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + return null; + } + + if (!\is_scalar($condition)) { + return null; + } + + $conditions[$key] = \is_bool($condition) ? ($condition ? 'true' : 'false') : (string) $condition; + } + + if (empty($conditions)) { + return null; + } + + $ref = $refPrefix . $model->getType(); + $compoundMapping[$ref] = $conditions; + + // Best-effort single-key mapping — last model with this value wins (fallback) + if (isset($conditions[$primaryKey])) { + $primaryMapping[$conditions[$primaryKey]] = $ref; + } + } + + // Verify compound uniqueness + $seen = []; + foreach ($compoundMapping as $conditions) { + $sig = \json_encode($conditions, JSON_THROW_ON_ERROR); + if (isset($seen[$sig])) { + return null; + } + $seen[$sig] = true; + } + + return \array_filter([ + 'propertyName' => $primaryKey, + 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, + 'x-propertyNames' => $allKeys, + 'x-mapping' => $compoundMapping, + ]); + } + protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ @@ -595,16 +771,6 @@ abstract class Format return 'EmailTemplateLocale'; } break; - case 'getSmsTemplate': - case 'updateSmsTemplate': - case 'deleteSmsTemplate': - switch ($param) { - case 'type': - return 'SmsTemplateType'; - case 'locale': - return 'SmsTemplateLocale'; - } - break; case 'createPlatform': switch ($param) { case 'type': diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index b611558826..fcff6ac2f4 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -316,9 +316,10 @@ class OpenAPI3 extends Format 'description' => $modelDescription, 'content' => [ $produces => [ - 'schema' => [ - 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model) - ], + 'schema' => \array_filter([ + 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), + 'discriminator' => $this->getDiscriminator($model, '#/components/schemas/'), + ]), ], ], ]; @@ -900,18 +901,30 @@ class OpenAPI3 extends Format $rule['type'] = ($rule['type']) ? $rule['type'] : 'none'; if (\is_array($rule['type'])) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + if ($rule['array']) { - $items = [ + $items = \array_filter([ 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), + ]); } else { - $items = [ + $items = \array_filter([ 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), + ]); } } else { $items = [ diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 413239f000..8d47766117 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,11 +322,12 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => [ + 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model) - ], + }, $model), + 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), + ]), ]; } else { // Response definition using one type @@ -880,14 +881,27 @@ class Swagger2 extends Format $rule['type'] = ($rule['type']) ?: 'none'; if (\is_array($rule['type'])) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $xDiscriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + if ($rule['array']) { - $items = [ - 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $xDiscriminator, + ]); } else { - $items = [ - 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $xDiscriminator, + ]); } } else { $items = [ diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 3f1ea794ab..32f0fa89a9 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -238,6 +238,9 @@ class Request extends UtopiaRequest if ($allowedParams !== null) { $params = array_intersect_key($params, array_flip($allowedParams)); } + if (!isset($params['project'])) { + $params['project'] = $this->getHeader('x-appwrite-project', ''); + } ksort($params); return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); } diff --git a/src/Appwrite/Utopia/Request/Filters/V19.php b/src/Appwrite/Utopia/Request/Filters/V19.php index e7789ac0f7..4f2be12367 100644 --- a/src/Appwrite/Utopia/Request/Filters/V19.php +++ b/src/Appwrite/Utopia/Request/Filters/V19.php @@ -35,6 +35,13 @@ class V19 extends Filter case 'functions.updateVariable': $content['secret'] = false; break; + case 'functions.getDeploymentDownload': + // Pre-1.7.0 clients call the legacy alias + // `/v1/functions/:functionId/deployments/:deploymentId/build/download`, + // which always downloaded the build output. The merged 1.7.0 endpoint + // requires an explicit `type` param, so force it to `output` here. + $content['type'] = 'output'; + break; } return $content; } diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 4f1e746775..7e4c5b8e41 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -73,10 +73,10 @@ class V22 extends Filter public function parse(array $content, string $model): array { switch ($model) { - case 'project.updateServiceStatus': + case 'project.updateService': $content = $this->parseUpdateServiceStatus($content); break; - case 'project.updateProtocolStatus': + case 'project.updateProtocol': $content = $this->parseUpdateProtocolStatus($content); break; case 'project.createKey': diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 5cd0e8366a..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -265,7 +265,6 @@ class Response extends SwooleResponse public const MODEL_VARIABLE = 'variable'; public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; - public const MODEL_SMS_TEMPLATE = 'smsTemplate'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; // Health @@ -613,6 +612,8 @@ class Response extends SwooleResponse throw new \Exception('Response body is not a valid JSON object.'); } + $this->payload = \is_array($data) ? $data : (array) $data; + $this ->setContentType(Response::CONTENT_TYPE_JSON, self::CHARSET_UTF8) ->send(\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); @@ -627,13 +628,12 @@ class Response extends SwooleResponse } /** - * Reset the sent flag so the response can be reused for another - * action execution (e.g. batched GraphQL queries that share one - * Response instance). + * Set the sent flag on the response. Pass false to allow reuse + * (e.g. batched GraphQL queries), true to prevent further writes. */ - public function clearSent(): static + public function setSent(bool $sent): static { - $this->sent = false; + $this->sent = $sent; return $this; } diff --git a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php index 3e162bb905..a721235f94 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoArgon2 extends Model { + public array $conditions = [ + 'type' => 'argon2', + ]; + public function __construct() { // No options if imported. If hashed by Appwrite, following configuration is available: diff --git a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php index 709dea1a41..ef15e5d50a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoBcrypt extends Model { + public array $conditions = [ + 'type' => 'bcrypt', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php index 509ee70c31..26b2886330 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoMd5 extends Model { + public array $conditions = [ + 'type' => 'md5', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php index f16792086e..7d8400edec 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoPhpass extends Model { + public array $conditions = [ + 'type' => 'phpass', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php index 4dda297d71..043a27166d 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScrypt extends Model { + public array $conditions = [ + 'type' => 'scrypt', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php index 40b9df1dad..24dd41bb77 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScryptModified extends Model { + public array $conditions = [ + 'type' => 'scryptMod', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoSha.php b/src/Appwrite/Utopia/Response/Model/AlgoSha.php index 2a0893adc4..52743ec26a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoSha.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoSha.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoSha extends Model { + public array $conditions = [ + 'type' => 'sha', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/Detection.php b/src/Appwrite/Utopia/Response/Model/Detection.php index 007182d1e9..9dfcc795d6 100644 --- a/src/Appwrite/Utopia/Response/Model/Detection.php +++ b/src/Appwrite/Utopia/Response/Model/Detection.php @@ -7,9 +7,16 @@ use Appwrite\Utopia\Response\Model; abstract class Detection extends Model { - public function __construct() + public function __construct(string $type) { $this + ->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => $type, + 'example' => $type, + 'enum' => [$type], + ]) ->addRule('variables', [ 'type' => Response::MODEL_DETECTION_VARIABLE, 'description' => 'Environment variables found in .env files', diff --git a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php index 4cdf37bbcf..00f318ba4a 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php @@ -8,7 +8,11 @@ class DetectionFramework extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'framework', + ]; + + parent::__construct('framework'); $this ->addRule('framework', [ diff --git a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php index 1e63929092..94368f890c 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php @@ -8,7 +8,11 @@ class DetectionRuntime extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'runtime', + ]; + + parent::__construct('runtime'); $this ->addRule('runtime', [ diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php new file mode 100644 index 0000000000..d1982e2f84 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -0,0 +1,29 @@ + 'framework', + ]; + + public function __construct() + { + parent::__construct( + 'Framework Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + 'frameworkProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'framework', + 'example' => 'framework', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php new file mode 100644 index 0000000000..f7ef1d7b5f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -0,0 +1,29 @@ + 'runtime', + ]; + + public function __construct() + { + parent::__construct( + 'Runtime Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + 'runtimeProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'runtime', + 'example' => 'runtime', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php b/src/Appwrite/Utopia/Response/Model/TemplateSMS.php deleted file mode 100644 index 2b19ef4878..0000000000 --- a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php +++ /dev/null @@ -1,32 +0,0 @@ -getProject()['$id']]; - // create team + // Create team — user becomes sole owner and only member $team = $this->client->call(Client::METHOD_POST, '/teams', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', @@ -58,7 +63,51 @@ class AccountConsoleClientTest extends Scope ]); $this->assertEquals($team['headers']['status-code'], 201); - $teamId = $team['body']['$id']; + // Account deletion should succeed even with active membership + $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + } + + /** + * Test that account deletion works when the user has no team memberships. + */ + public function testDeleteAccountWithoutMembership(): void + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ 'origin' => 'http://localhost', @@ -67,27 +116,7 @@ class AccountConsoleClientTest extends Scope 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); - $this->assertEquals($response['headers']['status-code'], 400); - - // DELETE TEAM - $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - $this->assertEquals($response['headers']['status-code'], 204); - - $this->assertEventually(function () use ($session) { - $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - - $this->assertEquals(204, $response['headers']['status-code']); - }, 10_000, 500); + $this->assertEquals(204, $response['headers']['status-code']); } public function testSessionAlert(): void diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 951ab179b3..49f0c4c245 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -4160,178 +4160,4 @@ class AccountCustomClientTest extends Scope $this->assertEquals(401, $verification3['headers']['status-code']); } - - /** - * Test that a new email/password session is immediately usable even when - * a concurrent request re-populates the user cache between the cache purge - * and session creation. - * - * Regression test for: purging the user cache BEFORE persisting the session - * allows a concurrent request (from a different Swoole worker) to re-cache - * a stale user document that lacks the new session, causing sessionVerify - * to fail with 401 on subsequent requests using the new session. - */ - public function testEmailPasswordSessionNotCorruptedByConcurrentRequests(): void - { - $projectId = $this->getProject()['$id']; - $endpoint = $this->client->getEndpoint(); - - $email = uniqid('race_', true) . getmypid() . '@localhost.test'; - $password = 'password123!'; - - // Create user - $response = $this->client->call(Client::METHOD_POST, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => 'Race Test User', - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - // Login to get session A - $responseA = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], [ - 'email' => $email, - 'password' => $password, - ]); - $this->assertEquals(201, $responseA['headers']['status-code']); - $sessionA = $responseA['cookies']['a_session_' . $projectId]; - - // Verify session A works - $verifyA = $this->client->call(Client::METHOD_GET, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => 'a_session_' . $projectId . '=' . $sessionA, - ]); - $this->assertEquals(200, $verifyA['headers']['status-code']); - - /** - * Race condition scenario: - * 1. Start login B via curl_multi (non-blocking) - * 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument - * (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead) - * 3. THEN add GET requests to curl_multi - these hit different workers and - * re-cache a stale user document (without session B) during the window - * between purgeCachedDocument and createDocument - * 4. After all complete, verify session B is usable - */ - for ($attempt = 0; $attempt < 5; $attempt++) { - $loginCookies = []; - - $multi = curl_multi_init(); - - // Start login B first (alone) - $loginHandle = curl_init("{$endpoint}/account/sessions/email"); - curl_setopt_array($loginHandle, [ - CURLOPT_POST => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'origin: http://localhost', - 'content-type: application/json', - "x-appwrite-project: {$projectId}", - ], - CURLOPT_POSTFIELDS => \json_encode([ - 'email' => $email, - 'password' => $password, - ]), - CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$loginCookies) { - if (\stripos($header, 'set-cookie:') === 0) { - $cookiePart = \trim(\substr($header, 11)); - $eqPos = \strpos($cookiePart, '='); - if ($eqPos !== false) { - $name = \substr($cookiePart, 0, $eqPos); - $rest = \substr($cookiePart, $eqPos + 1); - $semiPos = \strpos($rest, ';'); - $loginCookies[$name] = $semiPos !== false - ? \substr($rest, 0, $semiPos) - : $rest; - } - } - return \strlen($header); - }, - ]); - curl_multi_add_handle($multi, $loginHandle); - - // Drive the login transfer forward and wait for the server to start - // processing the login (past hash verification + cache purge). - $deadline = \microtime(true) + 0.15; // 150ms - do { - curl_multi_exec($multi, $active); - curl_multi_select($multi, 0.005); - } while (\microtime(true) < $deadline && $active); - - // NOW add GET requests - they arrive after the cache purge - // but before session creation (which is delayed by the usleep or I/O). - $getHandles = []; - for ($i = 0; $i < 10; $i++) { - $gh = curl_init("{$endpoint}/account"); - curl_setopt_array($gh, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - 'origin: http://localhost', - 'content-type: application/json', - "x-appwrite-project: {$projectId}", - "cookie: a_session_{$projectId}={$sessionA}", - ], - ]); - curl_multi_add_handle($multi, $gh); - $getHandles[] = $gh; - } - - // Drive all to completion - do { - $status = curl_multi_exec($multi, $active); - if ($active) { - curl_multi_select($multi, 0.05); - } - } while ($active && $status === CURLM_OK); - - $loginStatus = curl_getinfo($loginHandle, CURLINFO_HTTP_CODE); - - curl_multi_remove_handle($multi, $loginHandle); - curl_close($loginHandle); - foreach ($getHandles as $gh) { - curl_multi_remove_handle($multi, $gh); - curl_close($gh); - } - curl_multi_close($multi); - - $this->assertEquals(201, $loginStatus, 'Login for session B should succeed'); - - $sessionBCookie = $loginCookies["a_session_{$projectId}"] ?? null; - $this->assertNotNull($sessionBCookie, 'Session B cookie should be set'); - - // THE CRITICAL CHECK: verify session B is usable immediately - $verifyB = $this->client->call(Client::METHOD_GET, '/account', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => "a_session_{$projectId}={$sessionBCookie}", - ]); - - $this->assertEquals( - 200, - $verifyB['headers']['status-code'], - 'Session B must be immediately usable after login. ' - . 'A 401 here means a stale user cache (without the new session) was served. ' - . 'The fix is to create the session document BEFORE purging the user cache.' - ); - - // Clean up session B for next iteration - $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'cookie' => "a_session_{$projectId}={$sessionBCookie}", - ]); - } - } } diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 2c5e587fc2..f5f1d1864c 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -11539,4 +11539,162 @@ trait DatabasesBase $this->assertEquals('Product B', $rows['body'][$this->getRecordResource()][0]['name']); $this->assertEquals(139.99, $rows['body'][$this->getRecordResource()][0]['price']); } + public function testDocumentWithEmptyPayload(): void + { + $data = $this->setupCollection(); + $databaseId = $data['databaseId']; + $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [], + 'permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ]); + if ($this->getSupportForAttributes()) { + $this->assertEquals(400, $document['headers']['status-code']); + } else { + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals($data['moviesId'], $document['body'][$this->getContainerIdResponseKey()]); + $this->assertArrayNotHasKey('$collection', $document['body']); + $this->assertEquals($databaseId, $document['body']['$databaseId']); + $this->assertTrue(array_key_exists('$sequence', $document['body'])); + $this->assertIsString($document['body']['$sequence']); + + $documentId = $document['body']['$id']; + + $fetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()) + ); + + $this->assertEquals(200, $fetched['headers']['status-code']); + $this->assertEqualsCanonicalizing([ + '$id', + '$databaseId', + '$createdAt', + '$updatedAt', + '$permissions', + '$sequence', + $this->getContainerIdResponseKey(), + ], \array_keys($fetched['body'])); + $this->assertFalse(array_key_exists('$tenant', $fetched['body'])); + + $updated = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'data' => [ + 'status' => 'draft', + ], + ] + ); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('draft', $updated['body']['status']); + + $refetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()) + ); + + $this->assertEquals(200, $refetched['headers']['status-code']); + $this->assertEquals('draft', $refetched['body']['status']); + } + } + + /** + * API keys may set $createdAt / $updatedAt; invalid strings must return 400, not 500. + * Assertions are HTTP status codes only (no error body matching). + */ + public function testInvalidDate(): void + { + $data = $this->setupAttributes(); + $databaseId = $data['databaseId']; + $invalidDatetime = '1dfs:12:55+sdf:00'; + $validUpdatedAt = '2024-01-01T00:00:00Z'; + + $apiKeyHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + + $documentPayload = [ + 'title' => 'Captain America', + 'releaseYear' => 1944, + 'actors' => [ + 'Chris Evans', + 'Samuel Jackson', + ], + ]; + $permissions = [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ]; + + $invalidCreate = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => \array_merge($documentPayload, ['$updatedAt' => $invalidDatetime]), + 'permissions' => $permissions, + ]); + $this->assertEquals(400, $invalidCreate['headers']['status-code']); + + $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $data['moviesId']), $apiKeyHeaders, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => $documentPayload, + 'permissions' => $permissions, + ]); + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + $this->assertNotEmpty($documentId); + + $invalidPatch = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders, + [ + 'data' => [ + '$updatedAt' => $invalidDatetime, + ], + ] + ); + $this->assertEquals(400, $invalidPatch['headers']['status-code']); + + $updated = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders, + [ + 'data' => [ + '$updatedAt' => $validUpdatedAt, + ], + ] + ); + $this->assertEquals(200, $updated['headers']['status-code']); + + $refetched = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $data['moviesId'], $documentId), + $apiKeyHeaders + ); + $this->assertEquals(200, $refetched['headers']['status-code']); + } } diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php index 0187fc8463..f828994ea3 100644 --- a/tests/e2e/Services/Project/ProtocolsBase.php +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -241,6 +241,33 @@ trait ProtocolsBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateProtocolLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusForRest']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['protocolStatusForRest']); + } + // Helpers protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed @@ -254,7 +281,7 @@ trait ProtocolsBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId, $headers, [ 'enabled' => $enabled, ]); } diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index 1bc7ce5042..b5f94f8181 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -239,6 +239,33 @@ trait ServicesBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateServiceLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusForTeams']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['serviceStatusForTeams']); + } + // Helpers protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed @@ -252,7 +279,7 @@ trait ServicesBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId, $headers, [ 'enabled' => $enabled, ]); } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 7b9848e38f..59ff5e353c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1161,42 +1161,220 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); + } - // Temporary disabled until implemented - // /** Get Default SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); + #[Group('smtpAndTemplates')] + public function testSessionAlertLocaleFallback(): void + { + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('{{token}}', $response['body']['message']); + /** Create team */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test Team', + ]); + $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; - // /** Update SMS template */ - // $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders()), [ - // 'message' => 'Please verify your email {{token}}', - // ]); + /** Create project */ + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); + /** Configure custom SMTP pointing to maildev */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); - // /** Get Updated SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); + /** + * Set custom sessionAlert template with no explicit locale. + * When locale is omitted, the server stores it under the request's + * default locale (en), which is the same slot used as the system-wide + * fallback when a session's locale has no dedicated template. + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + // Intentionally no locale + 'subject' => 'Fallback sign-in alert', + 'message' => 'Fallback sign-in alert body', + 'senderName' => 'Fallback Mailer', + 'senderEmail' => 'fallback@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Fallback sign-in alert', $response['body']['subject']); + $this->assertEquals('Fallback sign-in alert body', $response['body']['message']); + $this->assertEquals('Fallback Mailer', $response['body']['senderName']); + $this->assertEquals('fallback@appwrite.io', $response['body']['senderEmail']); - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); + /** Set custom sessionAlert template for Slovak locale */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + 'locale' => 'sk', + 'subject' => 'Slovak sign-in alert', + 'message' => 'Slovak sign-in alert body', + 'senderName' => 'Slovak Mailer', + 'senderEmail' => 'sk@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Slovak sign-in alert', $response['body']['subject']); + $this->assertEquals('Slovak sign-in alert body', $response['body']['message']); + $this->assertEquals('Slovak Mailer', $response['body']['senderName']); + $this->assertEquals('sk@appwrite.io', $response['body']['senderEmail']); + + /** Enable session alerts */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'alerts' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** Verify alerts are enabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authSessionAlerts']); + + /** Create user (email + password) in the project */ + $userEmail = 'session-alert-' . uniqid() . '@appwrite.io'; + $password = 'password'; + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $userEmail, + 'password' => $password, + 'name' => 'Session Alert User', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Prime first session — the listener suppresses the alert on the very + * first session of a user, so this session is setup only. + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Create a new session with no locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Emails are delivered asynchronously via the mail queue, so maildev may + * still be catching up. The probe callback forces getLastEmailByAddress + * to keep polling until an email matching the expected `from` address + * appears — i.e. we await the new email rather than returning an older + * one already in the inbox from a previous session. + */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with German locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'de', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Probe on `from` address ensures we await a fallback-shaped email */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with Slovak locale — expect Slovak template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'sk', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Probe on `from` address ensures we await the Slovak email specifically */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('sk@appwrite.io', $email['from'][0]['address']); + }); + $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']); + $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']); + + /** Cleanup — delete the project */ + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + + /** Cleanup — delete the team */ + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); } public function testUpdateProjectAuthDuration(): void diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 9c768f00d1..ca07d45f46 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3295,6 +3295,277 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + public function testChannelMirrorEventsAcrossDatabasesAndTablesdb(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + /** + * Case 1: Trigger event through /databases route and verify both + * legacy collections/documents and tables/rows events are generated. + */ + $legacyDatabase = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror Legacy DB', + ]); + $this->assertEquals(201, $legacyDatabase['headers']['status-code']); + $legacyDatabaseId = $legacyDatabase['body']['$id']; + + $legacyCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Legacy Collection', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $legacyCollectionId = $legacyCollection['body']['$id']; + + $attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $attribute['headers']['status-code']); + + $this->assertEventually(function () use ($legacyDatabaseId, $legacyCollectionId) { + $attribute = $this->client->call(Client::METHOD_GET, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + + $this->assertEquals('available', $attribute['body']['status']); + }, 30000, 250); + + $legacyClient = $this->getWebsocket([ + "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents", + "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows", + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($legacyClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $legacyDocumentId = ID::unique(); + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $legacyDatabaseId . '/collections/' . $legacyCollectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'documentId' => $legacyDocumentId, + 'data' => [ + 'name' => 'legacy-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $document['headers']['status-code']); + + $legacyEvent = json_decode($legacyClient->receive(), true); + $this->assertEquals('event', $legacyEvent['type']); + $this->assertContains( + "databases.{$legacyDatabaseId}.collections.{$legacyCollectionId}.documents.{$legacyDocumentId}.create", + $legacyEvent['data']['events'] + ); + $this->assertContains( + "databases.{$legacyDatabaseId}.tables.{$legacyCollectionId}.rows.{$legacyDocumentId}.create", + $legacyEvent['data']['events'] + ); + $legacyClient->close(); + + /** + * Case 2: Trigger event through /tablesdb route and verify both + * tables/rows and legacy collections/documents events are generated. + */ + $tablesDatabase = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror TablesDB', + ]); + $this->assertEquals(201, $tablesDatabase['headers']['status-code']); + $tablesDatabaseId = $tablesDatabase['body']['$id']; + + $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'tableId' => ID::unique(), + 'name' => 'Mirror Table', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + $column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $column['headers']['status-code']); + + $this->assertEventually(function () use ($tablesDatabaseId, $tableId) { + $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/columns/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders())); + + $this->assertEquals('available', $column['body']['status']); + }, 120000, 500); + + $tablesClient = $this->getWebsocket([ + "databases.{$tablesDatabaseId}.tables.{$tableId}.rows", + "databases.{$tablesDatabaseId}.collections.{$tableId}.documents", + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($tablesClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $rowId = ID::unique(); + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $tablesDatabaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'rowId' => $rowId, + 'data' => [ + 'name' => 'tablesdb-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + + $tablesEvent = json_decode($tablesClient->receive(), true); + $this->assertEquals('event', $tablesEvent['type']); + $this->assertContains( + "databases.{$tablesDatabaseId}.tables.{$tableId}.rows.{$rowId}.create", + $tablesEvent['data']['events'] + ); + $this->assertContains( + "databases.{$tablesDatabaseId}.collections.{$tableId}.documents.{$rowId}.create", + $tablesEvent['data']['events'] + ); + $tablesClient->close(); + + /** + * Case 3: Trigger event through /documentsdb route and verify only + * documentsdb events are generated (no databases/tablesdb mirrors). + */ + $documentsDatabase = $this->client->call(Client::METHOD_POST, '/documentsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Mirror DocumentsDB', + ]); + $this->assertEquals(201, $documentsDatabase['headers']['status-code']); + $documentsDatabaseId = $documentsDatabase['body']['$id']; + + $documentsCollection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Mirror Documents Collection', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $documentsCollection['headers']['status-code']); + $documentsCollectionId = $documentsCollection['body']['$id']; + + $documentsClient = $this->getWebsocket([ + "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents", + 'documents', + ], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]); + + $connected = json_decode($documentsClient->receive(), true); + $this->assertEquals('connected', $connected['type']); + + $documentsDocumentId = ID::unique(); + $documentsDocument = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDatabaseId . '/collections/' . $documentsCollectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), [ + 'documentId' => $documentsDocumentId, + 'data' => [ + 'name' => 'documentsdb-route-create', + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $documentsDocument['headers']['status-code']); + + $documentsEvent = json_decode($documentsClient->receive(), true); + $this->assertEquals('event', $documentsEvent['type']); + $this->assertContains( + "documentsdb.{$documentsDatabaseId}.collections.{$documentsCollectionId}.documents.{$documentsDocumentId}.create", + $documentsEvent['data']['events'] + ); + $this->assertEmpty( + array_filter( + $documentsEvent['data']['events'], + fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.') + ) + ); + $documentsClient->close(); + } + public function testChannelDatabaseTransactionMultipleOperations() { $user = $this->getUser(); @@ -3968,7 +4239,16 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals(256, $name['body']['size']); $this->assertTrue($name['body']['required']); - sleep(2); + $this->assertEventually(function () use ($databaseId, $actorsId) { + $column = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders())); + + $this->assertEquals(200, $column['headers']['status-code']); + $this->assertEquals('available', $column['body']['status']); + }, 120000, 500); /** * Test Document Create @@ -4683,6 +4963,8 @@ class RealtimeCustomClientTest extends Scope $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.create", $response['data']['events']); + $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.'))); $this->assertNotEmpty($response['data']['payload']); $this->assertEquals('Chris Evans', $response['data']['payload']['name']); @@ -4714,6 +4996,8 @@ class RealtimeCustomClientTest extends Scope $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); + $this->assertEmpty(array_filter($response['data']['events'], fn (string $event) => \str_starts_with($event, 'databases.') || \str_starts_with($event, 'tablesdb.'))); $this->assertNotEmpty($response['data']['payload']); $this->assertEquals('Chris Evans 2', $response['data']['payload']['name']); @@ -5367,8 +5651,6 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('$id', $response['data']['payload']); $this->assertEquals(15, $response['data']['payload']['score']); - sleep(1); - try { $client->receive(); $this->fail('Should not receive duplicate event'); @@ -5400,8 +5682,6 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('$id', $response['data']['payload']); $this->assertEquals(12, $response['data']['payload']['score']); - sleep(1); - try { $client->receive(); $this->fail('Should not receive duplicate event'); diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index d1cb548016..60a4aefc85 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1050,6 +1050,28 @@ trait StorageBase $this->assertEquals(404, $file['headers']['status-code']); } + public function testFilePreviewAvifPublic(): void + { + $data = $this->setupBucketFile(); + $bucketId = $data['bucketId']; + $fileId = $data['fileId']; + $projectId = $this->getProject()['$id']; + + // Matches the customer's URL pattern: no headers, project + output in query string only + $preview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [ + 'content-type' => 'application/json', + ], [ + 'project' => $projectId, + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $preview['headers']['status-code']); + $this->assertEquals('image/avif', $preview['headers']['content-type']); + $this->assertNotEmpty($preview['body']); + } + public function testFilePreview(): void { $data = $this->setupBucketFile(); @@ -1069,6 +1091,49 @@ trait StorageBase $this->assertEquals(200, $preview['headers']['status-code']); $this->assertEquals('image/webp', $preview['headers']['content-type']); $this->assertNotEmpty($preview['body']); + + // Preview PNG as avif + $avifPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifPreview['headers']['status-code']); + $this->assertEquals('image/avif', $avifPreview['headers']['content-type']); + $this->assertNotEmpty($avifPreview['body']); + + // Preview JPEG as avif + $jpegFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-a/kitten-1.jpg'), 'image/jpeg', 'kitten-1.jpg'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $jpegFile['headers']['status-code']); + + $avifFromJpeg = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $jpegFile['body']['$id'] . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifFromJpeg['headers']['status-code']); + $this->assertEquals('image/avif', $avifFromJpeg['headers']['content-type']); + $this->assertNotEmpty($avifFromJpeg['body']); } public function testDeletePartiallyUploadedFile(): void diff --git a/tests/unit/Event/EventTest.php b/tests/unit/Event/EventTest.php index d050ce5f64..471dd5ad08 100644 --- a/tests/unit/Event/EventTest.php +++ b/tests/unit/Event/EventTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Event; use Appwrite\Event\Event; use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Utopia\Database\Document; require_once __DIR__ . '/../../../app/init.php'; @@ -115,7 +116,7 @@ class EventTest extends TestCase 'rowId' => 'prolog', ]); - $this->assertCount(22, $event); + $this->assertCount(42, $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog.create', $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.prolog', $event); $this->assertContains('databases.chaptersDB.tables.chapters.rows.*.create', $event); @@ -156,4 +157,62 @@ class EventTest extends TestCase $this->assertInstanceOf(InvalidArgumentException::class, $th, 'An invalid exception was thrown'); } } + + public function testGenerateMirrorEvents(): void + { + $legacyDatabase = new Document(['type' => 'legacy']); + $tableRowEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'rowId' => 'row-123', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tableRowEvents); + + $collectionDocumentEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'documentId' => 'doc-123', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.tables.assembly.rows.doc-123.update', $collectionDocumentEvents); + + $tableColumnEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].columns.[columnId].create', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'columnId' => 'status', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.collections.assembly.attributes.status.create', $tableColumnEvents); + + $collectionAttributeEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].attributes.[attributeId].create', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'attributeId' => 'status', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.tables.assembly.columns.status.create', $collectionAttributeEvents); + + $tablesDb = new Document(['type' => 'tablesdb']); + $tablesDbEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'assembly', + 'rowId' => 'row-123', + ], $tablesDb); + $this->assertContains('databases.factory-db.collections.assembly.documents.row-123.update', $tablesDbEvents); + $this->assertContains('tablesdb.factory-db.tables.assembly.rows.row-123.update', $tablesDbEvents); + $tableIdWithReservedWordEvents = Event::generateEvents('databases.[databaseId].tables.[tableId].rows.[rowId].update', [ + 'databaseId' => 'factory-db', + 'tableId' => 'rows-archive', + 'rowId' => 'row-123', + ], $legacyDatabase); + $this->assertContains('databases.factory-db.collections.rows-archive.documents.row-123.update', $tableIdWithReservedWordEvents); + $this->assertNotContains('databases.factory-db.collections.documents-archive.documents.row-123.update', $tableIdWithReservedWordEvents); + + $documentsDb = new Document(['type' => 'documentsdb']); + $documentsDbEvents = Event::generateEvents('databases.[databaseId].collections.[collectionId].documents.[documentId].update', [ + 'databaseId' => 'factory-db', + 'collectionId' => 'assembly', + 'documentId' => 'doc-123', + ], $documentsDb); + $this->assertContains('documentsdb.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents); + $this->assertNotContains('documentsdb.factory-db.tables.assembly.rows.doc-123.update', $documentsDbEvents); + $this->assertNotContains('databases.factory-db.collections.assembly.documents.doc-123.update', $documentsDbEvents); + } } diff --git a/tests/unit/GraphQL/BuilderTest.php b/tests/unit/GraphQL/BuilderTest.php index 3dd1bcadc7..9190ce3e78 100644 --- a/tests/unit/GraphQL/BuilderTest.php +++ b/tests/unit/GraphQL/BuilderTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\GraphQL; use Appwrite\GraphQL\Types\Mapper; use Appwrite\Utopia\Response; +use GraphQL\Type\Definition\NamedType; use PHPUnit\Framework\TestCase; use Swoole\Http\Response as SwooleResponse; @@ -24,6 +25,7 @@ class BuilderTest extends TestCase { $model = $this->response->getModel(Response::MODEL_TABLE); $type = Mapper::model(\ucfirst($model->getType())); - $this->assertEquals('Table', $type->name); + $this->assertInstanceOf(NamedType::class, $type); + $this->assertEquals('Table', $type->name()); } }