diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8256ddc7a..b02d021f1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,7 +210,7 @@ jobs: with: script: | const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB']; - const allModes = ['dedicated', 'shared_v1', 'shared_v2']; + const allModes = ['dedicated', 'shared']; const defaultDatabases = ['MongoDB']; const defaultModes = ['dedicated']; @@ -479,11 +479,8 @@ jobs: env: _APP_BROWSER_HOST: http://invalid-browser/v1 _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} - _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} - _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -557,11 +554,8 @@ jobs: env: _APP_OPTIONS_ABUSE: enabled _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} - _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} - _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -618,11 +612,8 @@ jobs: timeout-minutes: 5 env: _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} - _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} - _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable diff --git a/CHANGES.md b/CHANGES.md index 548c0d72b0..6894322043 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -892,7 +892,7 @@ * Unset index length by @fogelito in https://github.com/appwrite/appwrite/pull/8978 * Update base to 0.9.5 by @basert in https://github.com/appwrite/appwrite/pull/9005 * Sync main into 1.6.x by @TorstenDittmann in https://github.com/appwrite/appwrite/pull/9011 -* Improved shared tables V2 by @abnegate in https://github.com/appwrite/appwrite/pull/9013 +* Improved shared tables by @abnegate in https://github.com/appwrite/appwrite/pull/9013 * Ensure backwards compatibility for 1.6.x by @christyjacob4 in https://github.com/appwrite/appwrite/pull/9018 # Version 1.6.0 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/sdks.php b/app/config/sdks.php index 47dc8845b6..e89265b05e 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -300,6 +300,26 @@ return [ 'repoBranch' => 'main', 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/cursor-plugin/CHANGELOG.md'), ], + [ + 'key' => 'claude-plugin', + 'name' => 'ClaudePlugin', + 'version' => '0.1.0', + 'url' => 'https://github.com/appwrite/claude-plugin.git', + 'enabled' => true, + 'beta' => false, + 'dev' => false, + 'hidden' => false, + 'spec' => 'static', + 'family' => APP_SDK_PLATFORM_STATIC, + 'prism' => 'claude-plugin', + 'source' => \realpath(__DIR__ . '/../sdks/static-claude-plugin'), + 'gitUrl' => 'git@github.com:appwrite/claude-plugin.git', + 'gitRepoName' => 'claude-plugin', + 'gitUserName' => 'appwrite', + 'gitBranch' => 'dev', + 'repoBranch' => 'main', + 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'), + ], ], ], 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/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/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/shared/api.php b/app/controllers/shared/api.php index 5567281e67..bba00bede1 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -971,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 afcc2d2d0f..b72f3b7f34 100644 --- a/app/http.php +++ b/app/http.php @@ -413,27 +413,19 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke $projectCollections = $collections['projects']; $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); - $sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1); - $documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); - $documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); - $documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1); - $vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); - $vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); - $vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1); $cache = $app->getResource('cache'); - // All shared tables V2 pools that need project metadata collections - $sharedTablesV2All = \array_values(\array_unique(\array_filter([ - ...$sharedTablesV2, - ...$documentsSharedTablesV2, - ...$vectorSharedTablesV2, + // All shared tables pools that need project metadata collections + $allSharedTables = \array_values(\array_unique(\array_filter([ + ...$sharedTables, + ...$documentsSharedTables, + ...$vectorSharedTables, ]))); - foreach ($sharedTablesV2All as $hostname) { + foreach ($allSharedTables as $hostname) { Span::init('database.setup'); Span::add('database.hostname', $hostname); 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/realtime.php b/app/realtime.php index 955832e93a..bd493dc64f 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; @@ -1051,9 +1070,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } - // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context. - $realtime->connections[$connection]['authorization'] = $authorization; - $responsePayload = json_encode([ 'type' => 'response', 'data' => [ @@ -1083,6 +1099,58 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re break; + case 'unsubscribe': + if (!\is_array($message['data']) || !\array_is_list($message['data'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); + } + + // Validate every payload before executing any removal so an invalid entry + // later in the batch does not leave earlier entries half-applied on the server. + $validatedIds = []; + foreach ($message['data'] as $payload) { + if ( + !\is_array($payload) + || !\array_key_exists('subscriptionId', $payload) + || !\is_string($payload['subscriptionId']) + || $payload['subscriptionId'] === '' + ) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.'); + } + $validatedIds[] = $payload['subscriptionId']; + } + + $unsubscribeResults = []; + foreach ($validatedIds as $subscriptionId) { + $wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId); + $unsubscribeResults[] = [ + 'subscriptionId' => $subscriptionId, + 'removed' => $wasRemoved, + ]; + } + + $unsubscribeResponsePayload = json_encode([ + 'type' => 'response', + 'data' => [ + 'to' => 'unsubscribe', + 'success' => true, + 'subscriptions' => $unsubscribeResults, + ], + ]); + + $server->send([$connection], $unsubscribeResponsePayload); + + if ($project !== null && !$project->isEmpty()) { + $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); + + if ($unsubscribeOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes, + ], $project->getId()); + } + } + + break; + default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } 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 d7681742a5..bcbd59a636 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.*", diff --git a/composer.lock b/composer.lock index 423b5bef9c..19d7af6719 100644 --- a/composer.lock +++ b/composer.lock @@ -4271,16 +4271,16 @@ }, { "name": "utopia-php/http", - "version": "0.34.20", + "version": "0.34.21", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "d6b360d555022d16c16d40be51f86180364819f8" + "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/d6b360d555022d16c16d40be51f86180364819f8", - "reference": "d6b360d555022d16c16d40be51f86180364819f8", + "url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24", + "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24", "shasum": "" }, "require": { @@ -4319,22 +4319,22 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.20" + "source": "https://github.com/utopia-php/http/tree/0.34.21" }, - "time": "2026-04-12T14:25:22+00:00" + "time": "2026-04-19T19:44:04+00:00" }, { "name": "utopia-php/image", - "version": "0.8.4", + "version": "0.8.6", "source": { "type": "git", "url": "https://github.com/utopia-php/image.git", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65" + "reference": "85ab7027873e11bc901110d8f7830252247ba724" }, "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/85ab7027873e11bc901110d8f7830252247ba724", + "reference": "85ab7027873e11bc901110d8f7830252247ba724", "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.6" }, - "time": "2025-06-03T08:32:20+00:00" + "time": "2026-04-19T12:52:59+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", @@ -5462,16 +5464,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.11", + "version": "1.20", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "c714ee52659ef5968b3372ff4da0e407140a6250" + "reference": "525f0630520c95100fcdfb63c9dac859c1d02588" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/c714ee52659ef5968b3372ff4da0e407140a6250", - "reference": "c714ee52659ef5968b3372ff4da0e407140a6250", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588", + "reference": "525f0630520c95100fcdfb63c9dac859c1d02588", "shasum": "" }, "require": { @@ -5507,9 +5509,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.11" + "source": "https://github.com/appwrite/sdk-generator/tree/1.20" }, - "time": "2026-04-11T02:42:32+00:00" + "time": "2026-04-20T05:45:00+00:00" }, { "name": "brianium/paratest", @@ -6218,11 +6220,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.46", + "version": "2.1.50", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", - "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", "shasum": "" }, "require": { @@ -6267,20 +6269,20 @@ "type": "github" } ], - "time": "2026-04-01T09:25:14+00:00" + "time": "2026-04-17T13:10:32+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { @@ -6289,7 +6291,6 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", "sebastian/environment": "^8.0.3", @@ -6336,7 +6337,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { @@ -6356,7 +6357,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", @@ -6617,16 +6618,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.17", + "version": "12.5.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "85b62adab1a340982df64e66daa4a4435eb5723b" + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b", - "reference": "85b62adab1a340982df64e66daa4a4435eb5723b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", "shasum": "" }, "require": { @@ -6640,15 +6641,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.6", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/comparator": "^7.1.6", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.4", + "sebastian/environment": "^8.1.0", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -6695,7 +6696,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" }, "funding": [ { @@ -6703,7 +6704,7 @@ "type": "other" } ], - "time": "2026-04-08T03:04:19+00:00" + "time": "2026-04-18T06:12:49+00:00" }, { "name": "sebastian/cli-parser", @@ -6776,16 +6777,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.5", + "version": "7.1.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63" + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63", - "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { @@ -6844,7 +6845,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" }, "funding": [ { @@ -6864,7 +6865,7 @@ "type": "tidelift" } ], - "time": "2026-04-08T04:43:00+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", @@ -6993,16 +6994,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.4", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { @@ -7017,7 +7018,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -7045,7 +7046,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { @@ -7065,7 +7066,7 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:05:40+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", @@ -7861,7 +7862,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -7919,7 +7920,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" }, "funding": [ { @@ -7943,7 +7944,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -8004,7 +8005,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" }, "funding": [ { diff --git a/docker-compose.yml b/docker-compose.yml index 391d71fb48..2e53b67901 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -242,7 +242,6 @@ services: - _APP_EXPERIMENT_LOGGING_PROVIDER - _APP_EXPERIMENT_LOGGING_CONFIG - _APP_DATABASE_SHARED_TABLES - - _APP_DATABASE_SHARED_TABLES_V1 - _APP_DATABASE_SHARED_NAMESPACE - _APP_FUNCTIONS_CREATION_ABUSE_LIMIT - _APP_CUSTOM_DOMAIN_DENY_LIST @@ -462,7 +461,6 @@ services: - _APP_EXECUTOR_SECRET - _APP_EXECUTOR_HOST - _APP_DATABASE_SHARED_TABLES - - _APP_DATABASE_SHARED_TABLES_V1 - _APP_EMAIL_CERTIFICATES - _APP_MAINTENANCE_RETENTION_AUDIT - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE 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/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/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/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f1d806bcc5..eeb1387674 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -114,14 +114,24 @@ class Realtime extends MessagingAdapter } } - // Keep userId from onOpen/authentication when provided. - // Fallback to existing stored value for subsequent subscribe upserts. - $this->connections[$identifier] = [ + // Union channels/roles across all subscriptions on the connection; overwriting would + // leave getSubscriptionMetadata and full unsubscribe operating on stale state. + $existing = $this->connections[$identifier] ?? []; + $existingChannels = $existing['channels'] ?? []; + $existingRoles = $existing['roles'] ?? []; + + $entry = [ 'projectId' => $projectId, - 'roles' => $roles, - 'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''), - 'channels' => $channels + 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), + 'userId' => $userId ?? ($existing['userId'] ?? ''), + 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), ]; + + if (\array_key_exists('authorization', $existing)) { + $entry['authorization'] = $existing['authorization']; + } + + $this->connections[$identifier] = $entry; } /** @@ -206,6 +216,87 @@ class Realtime extends MessagingAdapter } } + /** + * Removes a single subscription from a connection, keeping the connection alive so + * the client can resubscribe. Idempotent — returns true only when something was removed. + * + * @param mixed $connection + * @param string $subscriptionId + * @return bool + */ + public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool + { + $projectId = $this->connections[$connection]['projectId'] ?? ''; + if ($projectId === '' || !isset($this->subscriptions[$projectId])) { + return false; + } + + $removed = false; + + foreach ($this->subscriptions[$projectId] as $role => $byChannel) { + foreach ($byChannel as $channel => $byConnection) { + if (!isset($byConnection[$connection][$subscriptionId])) { + continue; + } + + unset($this->subscriptions[$projectId][$role][$channel][$connection][$subscriptionId]); + $removed = true; + + if (empty($this->subscriptions[$projectId][$role][$channel][$connection])) { + unset($this->subscriptions[$projectId][$role][$channel][$connection]); + } + if (empty($this->subscriptions[$projectId][$role][$channel])) { + unset($this->subscriptions[$projectId][$role][$channel]); + } + } + if (empty($this->subscriptions[$projectId][$role])) { + unset($this->subscriptions[$projectId][$role]); + } + } + + if (empty($this->subscriptions[$projectId])) { + unset($this->subscriptions[$projectId]); + } + + if ($removed) { + $this->recomputeConnectionState($connection); + } + + return $removed; + } + + /** + * Recomputes the cached channels on the connection entry from the subscriptions tree. + * Called after per-subscription removal so stale channel entries do not linger for later reads. + * + * Roles are deliberately NOT recomputed here. They represent the connection's authorization + * context (set at onOpen, replaced on `authentication` / permission-change) and must survive + * per-subscription removal — otherwise a client that unsubscribes every subscription and then + * resubscribes would subscribe with an empty roles array and silently receive nothing. + * + * @param mixed $connection + * @return void + */ + private function recomputeConnectionState(mixed $connection): void + { + if (!isset($this->connections[$connection])) { + return; + } + + $projectId = $this->connections[$connection]['projectId'] ?? ''; + $channels = []; + + foreach ($this->subscriptions[$projectId] ?? [] as $byChannel) { + foreach ($byChannel as $channel => $byConnection) { + if (isset($byConnection[$connection])) { + $channels[$channel] = true; + } + } + } + + $this->connections[$connection]['channels'] = \array_keys($channels); + } + /** * Checks if Channel has a subscriber. * @param string $projectId 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/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php index 3d07c65250..294a6712a9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php @@ -49,11 +49,6 @@ class Create extends Action $databaseOverride = ''; $dbScheme = ''; $databaseSharedTables = []; - $databaseSharedTablesV1 = []; - $databaseSharedTablesV2 = []; - $projectSharedTables = []; - $projectSharedTablesV1 = []; - $projectSharedTablesV2 = []; switch ($databasetype) { case DOCUMENTSDB: @@ -62,7 +57,6 @@ class Create extends Action $databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'); $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))); - $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''))); break; case VECTORSDB: $databases = Config::getParam('pools-vectorsdb', []); @@ -70,7 +64,6 @@ class Create extends Action $databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'); $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))); - $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''))); break; default: // legacy/tablesdb @@ -78,8 +71,7 @@ class Create extends Action return $dsn; } - $isSharedTablesV1 = false; - $isSharedTablesV2 = false; + $isSharedTables = false; if (!empty($dsn)) { try { @@ -90,10 +82,7 @@ class Create extends Action } $projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); - $projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1); - $isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1); - $isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2); + $isSharedTables = \in_array($dsnHost, $projectSharedTables); } if ($region !== 'default') { @@ -102,18 +91,14 @@ class Create extends Action return str_contains($value, $region); }); } - $databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1); $index = \array_search($databaseOverride, $databases); if ($index !== false) { $selectedDsn = $databases[$index]; } else { if (!empty($dsn) && !empty($databaseSharedTables)) { - $beforeFilter = \array_values($databases); - if ($isSharedTablesV1) { - $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1)); - } elseif ($isSharedTablesV2) { - $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2)); + if ($isSharedTables) { + $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTables)); } else { $databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables)); } 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 0071b03d2d..87e936a965 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -144,7 +144,8 @@ class Builds extends Action $log, $executor, $plan, - $platform + $platform, + (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -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(), 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/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index 9070962e7d..c509a565cd 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -21,8 +21,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\UID; use Utopia\DSN\DSN; use Utopia\Platform\Scope\HTTP; @@ -209,32 +207,16 @@ class Create extends Action } $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); $projectTables = !\in_array($dsn->getHost(), $sharedTables); - $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); - $sharedTablesV2 = !$projectTables && !$sharedTablesV1; - $sharedTables = $sharedTablesV1 || $sharedTablesV2; - if (!$sharedTablesV2) { + if ($projectTables) { $adapter = new DatabasePool($pools->get($dsn->getHost())); $dbForProject = new Database($adapter, $cache); - $dbForProject->setDatabase(APP_DATABASE); - - if ($sharedTables) { - $tenant = null; - if ($sharedTablesV1) { - $tenant = $project->getSequence(); - } - $dbForProject - ->setSharedTables(true) - ->setTenant($tenant) - ->setNamespace($dsn->getParam('namespace')); - } else { - $dbForProject - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } + $dbForProject + ->setDatabase(APP_DATABASE) + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); $create = true; @@ -244,27 +226,11 @@ class Create extends Action $create = false; } - if ($create || $projectTables) { - $adapter = new AdapterDatabase($dbForProject); - $audit = new Audit($adapter); - $audit->setup(); - } + $adapter = new AdapterDatabase($dbForProject); + $audit = new Audit($adapter); + $audit->setup(); - if (!$create && $sharedTablesV1) { - $adapter = new AdapterDatabase($dbForProject); - $attributes = $adapter->getAttributeDocuments(); - $indexes = $adapter->getIndexDocuments(); - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom('audit'), - '$permissions' => [Permission::create(Role::any())], - 'name' => 'audit', - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } - - if ($create || $sharedTablesV1) { + if ($create) { /** @var array $collections */ $collections = Config::getParam('collections', [])['projects'] ?? []; @@ -279,37 +245,7 @@ class Create extends Action try { $dbForProject->createCollection($key, $attributes, $indexes); } catch (Duplicate) { - try { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } catch (Duplicate) { - // Metadata already exists from concurrent creation - } - } catch (\Throwable $e) { - // PostgreSQL adapter may throw a non-Duplicate exception when - // a table or index already exists during concurrent project - // creation in shared mode. Treat as duplicate if metadata - // can be created successfully. - try { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } catch (Duplicate) { - // Metadata already exists from concurrent creation - } catch (\Throwable) { - throw $e; // Rethrow original if metadata creation also fails - } + // Collection already exists } } } 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/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 526ea304de..aac738915d 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Tasks; use Appwrite\SDK\Language\AgentSkills; use Appwrite\SDK\Language\Android; use Appwrite\SDK\Language\Apple; +use Appwrite\SDK\Language\ClaudePlugin; use Appwrite\SDK\Language\CLI; use Appwrite\SDK\Language\CursorPlugin; use Appwrite\SDK\Language\Dart; @@ -451,6 +452,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND case 'cursor-plugin': $config = new CursorPlugin(); break; + case 'claude-plugin': + $config = new ClaudePlugin(); + break; default: throw new \Exception('Language "' . $language['key'] . '" not supported'); } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index f4978780a1..6801d12b77 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -651,11 +651,8 @@ class Deletes extends Action ]; $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); $projectTables = !\in_array($dsn->getHost(), $sharedTables); - $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); - $sharedTablesV2 = !$projectTables && !$sharedTablesV1; $allDatabases = [ new Document([ @@ -758,23 +755,7 @@ class Deletes extends Action ), $databasesToClean )); - } elseif ($sharedTablesV1) { - /** - * Temporary disabling deletes for internal collections - */ - $queries = \array_map( - fn ($id) => Query::notEqual('$id', $id), - $projectCollectionIds - ); - - $queries[] = Query::orderAsc(); - - $this->deleteByGroup( - Database::METADATA, - $queries, - $dbForProject - ); - } elseif ($sharedTablesV2) { + } else { $queries = \array_map( fn ($id) => Query::notEqual('$id', $id), $projectCollectionIds diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index d88ab23535..eac5100f68 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -196,9 +196,25 @@ class Migrations extends Action $migrationOptions = $migration->getAttribute('options'); /** @var Database|null $projectDB */ $projectDB = null; - if ($credentials['projectId']) { + $useAppwriteApiSource = false; + if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) { + throw new \Exception('Source projectId is required for Appwrite migrations'); + } + + if (! empty($credentials['projectId'])) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); - $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + if ($this->sourceProject->isEmpty()) { + throw new \Exception('Source project not found for provided projectId'); + } + + $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); + $destinationRegion = $this->project->getAttribute('region', 'default'); + $useAppwriteApiSource = $source === SourceAppwrite::getName() + && $destination === DestinationAppwrite::getName() + && $sourceRegion !== $destinationRegion; + if (! $useAppwriteApiSource) { + $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + } } $getDatabasesDB = fn (Document $database): Database => $this->getDatabasesDBForProject($database); @@ -234,7 +250,7 @@ class Migrations extends Action $credentials['endpoint'], $credentials['apiKey'], $getDatabasesDB, - SourceAppwrite::SOURCE_DATABASE, + $useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE, $projectDB, $queries ), @@ -580,9 +596,10 @@ class Migrations extends Action protected function getDatabasesDBForProject(Document $database) { - if ($this->sourceProject) { + if (isset($this->sourceProject) && ! $this->sourceProject->isEmpty()) { return ($this->getDatabasesDB)($database, $this->sourceProject); } + return ($this->getDatabasesDB)($database); } 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 04d2813e30..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 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/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/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index edce428e0f..6376875157 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -164,6 +164,20 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope return $response; } + /** + * @param array> $payloadEntries + * @return array + */ + private function sendUnsubscribeMessage(WebSocketClient $client, array $payloadEntries): array + { + $client->send(\json_encode([ + 'type' => 'unsubscribe', + 'data' => $payloadEntries, + ])); + + return \json_decode($client->receive(), true); + } + /** * subscriptionId: update with id from connected, create by omitting id, explicit new id, * duplicate id in one bulk (last wins), mixed bulk, idempotent repeat, empty queries → select-all. @@ -293,6 +307,282 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client->close(); } + /** + * Update a subscription's queries/channels by reusing its subscriptionId. + * Verifies the update takes effect on live event filtering (not just the response echo), + * sibling subscriptions are untouched, unknown ids upsert as new, empty queries fall + * back to select-all, and a removed id can be recreated by subscribing again. + */ + public function testUpdateSubscriptionAndEdgeCases(): void + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query(['project' => $projectId]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 10, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + $triggerAccountEvent = function () use ($projectId, $session): void { + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => 'Update Sub Test ' . \uniqid()]); + }; + + // subA matches current user, subB never matches + $created = $this->sendSubscribeMessage($client, [ + [ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ], + [ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', ['no-match-initial'])->toString()], + ], + ]); + $subA = $created['data']['subscriptions'][0]['subscriptionId']; + $subB = $created['data']['subscriptions'][1]['subscriptionId']; + $this->assertNotSame($subA, $subB); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subA], $event['data']['subscriptions']); + + // Swap: A -> non-matching, B -> matching. Same ids returned, server-side filter swaps. + $swap = $this->sendSubscribeMessage($client, [ + [ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', ['no-match-swapped'])->toString()], + ], + [ + 'subscriptionId' => $subB, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ], + ]); + $this->assertSame($subA, $swap['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($subB, $swap['data']['subscriptions'][1]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Sibling isolation: updating only subA must leave subB's matching filter intact. + $isolation = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($subA, $isolation['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Empty queries on update -> select-all; subA still matches every event on the channel. + $empty = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [], + ]]); + $this->assertSame($subA, $empty['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Unknown subscriptionId upserts as a new subscription. + $ghostId = ID::unique(); + $ghost = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $ghostId, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($ghostId, $ghost['data']['subscriptions'][0]['subscriptionId']); + $this->assertNotSame($subA, $ghostId); + $this->assertNotSame($subB, $ghostId); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']); + + // Update after unsubscribe: subscribing with the removed id recreates it. + $unsub = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertTrue($unsub['data']['subscriptions'][0]['removed']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subB, $ghostId], $event['data']['subscriptions']); + + $recreated = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($subA, $recreated['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']); + + $client->close(); + } + + public function testUnsubscribeRemovesOnlyMatchingSubscription(): void + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query(['project' => $projectId]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 10, + ] + ); + + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + // Two subscriptions on the `account` channel, both matching the current user + $r1 = $this->sendSubscribeMessage($client, [[ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $subA = $r1['data']['subscriptions'][0]['subscriptionId']; + + $r2 = $this->sendSubscribeMessage($client, [[ + 'channels' => ['account'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $subB = $r2['data']['subscriptions'][0]['subscriptionId']; + + $this->assertNotSame($subA, $subB); + + // Trigger an event -- both subscriptions should match + $name = 'Unsubscribe Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Unsubscribe subA only + $unsubA = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertEquals('response', $unsubA['type']); + $this->assertEquals('unsubscribe', $unsubA['data']['to']); + $this->assertTrue($unsubA['data']['success']); + $this->assertCount(1, $unsubA['data']['subscriptions']); + $this->assertSame($subA, $unsubA['data']['subscriptions'][0]['subscriptionId']); + $this->assertTrue($unsubA['data']['subscriptions'][0]['removed']); + + // Trigger another event -- only subB should match now + $name = 'Unsubscribe Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Idempotent: unsubscribing subA again reports removed=false + $unsubAgain = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertTrue($unsubAgain['data']['success']); + $this->assertFalse($unsubAgain['data']['subscriptions'][0]['removed']); + + // Connection is still alive -- ping still works + $client->send(\json_encode(['type' => 'ping'])); + $pong = \json_decode($client->receive(), true); + $this->assertEquals('pong', $pong['type']); + + // Invalid payloads are rejected + $errNonString = $this->sendUnsubscribeMessage($client, [['subscriptionId' => 123]]); + $this->assertEquals('error', $errNonString['type']); + $this->assertStringContainsString('subscriptionId', $errNonString['data']['message']); + + $errEmpty = $this->sendUnsubscribeMessage($client, [['subscriptionId' => '']]); + $this->assertEquals('error', $errEmpty['type']); + + $errMissing = $this->sendUnsubscribeMessage($client, [['channels' => ['foo']]]); + $this->assertEquals('error', $errMissing['type']); + + $errNonList = $this->sendUnsubscribeMessage($client, ['subscriptionId' => $subB]); + $this->assertEquals('error', $errNonList['type']); + + // A batch with a valid id followed by an invalid one must be rejected atomically: + // the valid id must remain subscribed, not be quietly removed before validation fails. + $partial = $this->sendUnsubscribeMessage($client, [ + ['subscriptionId' => $subB], + ['subscriptionId' => 999], + ]); + $this->assertEquals('error', $partial['type']); + + $name = 'Partial Rejection Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Bulk unsubscribe: remaining subB plus a never-existed id -- response mirrors input order + $bulk = $this->sendUnsubscribeMessage($client, [ + ['subscriptionId' => $subB], + ['subscriptionId' => 'does-not-exist'], + ]); + $this->assertTrue($bulk['data']['success']); + $this->assertCount(2, $bulk['data']['subscriptions']); + $this->assertSame($subB, $bulk['data']['subscriptions'][0]['subscriptionId']); + $this->assertTrue($bulk['data']['subscriptions'][0]['removed']); + $this->assertSame('does-not-exist', $bulk['data']['subscriptions'][1]['subscriptionId']); + $this->assertFalse($bulk['data']['subscriptions'][1]['removed']); + + $client->close(); + } + public function testInvalidQueryShouldNotSubscribe(): void { $user = $this->getUser(); 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/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 4b2474c760..f48be46202 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -147,6 +147,193 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } + public function testSubscribeUnionsChannelsAndRoles(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('123'))->toString()], + ['documents'], + ); + + $realtime->subscribe( + '1', + 1, + 'sub-b', + [Role::users()->toString()], + ['files'], + ); + + $connection = $realtime->connections[1]; + + $this->assertContains('documents', $connection['channels']); + $this->assertContains('files', $connection['channels']); + $this->assertContains(Role::user(ID::custom('123'))->toString(), $connection['roles']); + $this->assertContains(Role::users()->toString(), $connection['roles']); + $this->assertCount(2, $connection['channels']); + $this->assertCount(2, $connection['roles']); + } + + public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('123'))->toString()], + ['documents'], + ); + + $realtime->subscribe( + '1', + 1, + 'sub-b', + [Role::users()->toString()], + ['files'], + ); + + $removed = $realtime->unsubscribeSubscription(1, 'sub-a'); + + $this->assertTrue($removed); + $this->assertArrayHasKey(1, $realtime->connections); + + // sub-a is fully cleaned from the tree + $this->assertArrayNotHasKey( + Role::user(ID::custom('123'))->toString(), + $realtime->subscriptions['1'] + ); + + // sub-b still delivers + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['files'], + ], + ]; + $receivers = array_keys($realtime->getSubscribers($event)); + $this->assertEquals([1], $receivers); + + // Channels recomputed: sub-a's channel is gone + $this->assertSame(['files'], $realtime->connections[1]['channels']); + + // Roles are connection-level auth context — union of both subscribe calls preserved + $this->assertContains(Role::user(ID::custom('123'))->toString(), $realtime->connections[1]['roles']); + $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); + } + + public function testUnsubscribeSubscriptionIsIdempotent(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertFalse($realtime->unsubscribeSubscription(1, 'does-not-exist')); + $this->assertFalse($realtime->unsubscribeSubscription(99, 'sub-a')); + + // Original sub is untouched + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['documents'], + ], + ]; + $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); + } + + public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a')); + + $this->assertArrayHasKey(1, $realtime->connections); + $this->assertSame([], $realtime->connections[1]['channels']); + // Roles preserved so a later resubscribe on the same connection still has auth context + $this->assertSame([Role::users()->toString()], $realtime->connections[1]['roles']); + $this->assertArrayNotHasKey('1', $realtime->subscriptions); + } + + public function testResubscribeAfterUnsubscribingLastSubDelivers(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a')); + + // Simulate the message-based subscribe path reading stored roles + $storedRoles = $realtime->connections[1]['roles']; + $this->assertNotEmpty($storedRoles, 'connection roles must survive per-subscription removal'); + + $realtime->subscribe('1', 1, 'sub-b', $storedRoles, ['files']); + + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['files'], + ], + ]; + $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); + } + + public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void + { + $realtime = new Realtime(); + + // Mirrors the onOpen empty-channels path: subscribe with '' id, empty channels + $realtime->subscribe( + '1', + 1, + '', + [Role::users()->toString()], + [], + [], + 'user-123', + ); + + // Now a real subscription comes in via the subscribe message type + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('user-123'))->toString()], + ['documents'], + ); + + $this->assertSame('user-123', $realtime->connections[1]['userId']); + $this->assertContains('documents', $realtime->connections[1]['channels']); + $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); + $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); + } + public function testConvertChannelsGuest(): void { $user = new Document([