Merge branch '1.9.x' into feat-add-telemetry-for-ss-success-rates

This commit is contained in:
Harsh Mahajan 2026-04-21 13:07:40 +05:30 committed by GitHub
commit c6672e93cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 1346 additions and 620 deletions

View file

@ -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

View file

@ -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

View file

@ -9,11 +9,5 @@ return [
'mfaChallenge',
'sessionAlert',
'otpSession'
],
'sms' => [
'verification',
'login',
'invitation',
'mfaChallenge'
]
];

View file

@ -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'),
],
],
],

View file

@ -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',

View file

@ -2272,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();
@ -2582,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();
@ -2975,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'];
@ -3733,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
@ -4041,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();
@ -4340,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'))

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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());

View file

@ -394,10 +394,27 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::success('Worker ' . $workerId . ' started successfully');
$telemetry = getTelemetry($workerId);
$realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000];
$workerTelemetryAttributes = ['workerId' => (string) $workerId];
$register->set('telemetry', fn () => $telemetry);
$register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes);
$register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers'));
$register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients'));
$register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions'));
$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' => $realtimeDelayBuckets],
));
$register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram(
name: 'realtime.server.arrival_delay',
unit: 'ms',
advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets],
));
$register->get('telemetry.workerCounter')->add(1);
$attempts = 0;
$start = time();
@ -514,12 +531,28 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) {
$event = json_decode($payload, true);
$eventTimestamp = $event['data']['timestamp'] ?? null;
if (\is_string($eventTimestamp)) {
try {
$eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC'));
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$eventTimestampMs = (float) $eventDate->format('U.u') * 1000;
$nowTimestampMs = (float) $now->format('U.u') * 1000;
$arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs);
$register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs);
} catch (\Throwable) {
// Ignore invalid timestamp payloads.
}
}
if ($event['permissionsChanged'] && isset($event['userId'])) {
$projectId = $event['project'];
$userId = $event['userId'];
if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) {
$connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId]));
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$consoleDatabase = getConsoleDB();
$project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId));
$database = getProjectDB($project);
@ -550,6 +583,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
if ($authorization !== null) {
$realtime->connections[$connection]['authorization'] = $authorization;
}
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
}
}
@ -592,6 +631,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, new \DateTimeZone('UTC'));
$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;
@ -621,6 +674,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
Console::error('Failed to restart pub/sub...');
});
$server->onWorkerStop(function (int $workerId) use ($register) {
Console::warning('Worker ' . $workerId . ' stopping');
try {
$register->get('telemetry.workerCounter')->add(-1);
} catch (\Throwable $th) {
Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage());
}
});
$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) {
global $container;
$request = new Request($request);
@ -709,6 +772,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void {
$register->get('telemetry.connectionCounter')->add(1);
$register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes'));
$register->get('telemetry.connectionCreatedCounter')->add(1);
$stats->set($projectId, [
@ -773,6 +837,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$mapping[$index] = $subscriptionId;
}
if (!empty($subscriptions)) {
$register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes'));
}
$realtime->connections[$connection]['authorization'] = $authorization;
@ -827,7 +894,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
}
});
$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) {
$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) {
$project = null;
$authorization = null;
try {
@ -941,6 +1008,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
$meta = $realtime->getSubscriptionMetadata($connection);
$realtime->unsubscribe($connection);
@ -965,6 +1033,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$realtime->connections[$connection]['authorization'] = $authorization;
}
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
$user = $response->output($user, Response::MODEL_ACCOUNT);
$authResponsePayloadJson = json_encode([
@ -1009,6 +1083,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
// bulk validation + parsing before subscribing
$parsedPayloads = [];
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
foreach ($message['data'] as $payload) {
if (!\is_array($payload)) {
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.');
@ -1050,9 +1125,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
$queries = $parsedPayload['queries'];
$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;
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
$responsePayload = json_encode([
'type' => 'response',
@ -1083,6 +1160,65 @@ $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.');
}
$subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection));
// 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,
];
}
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
if ($subscriptionDelta !== 0) {
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
}
$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.');
}
@ -1121,6 +1257,11 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
if (array_key_exists($connection, $realtime->connections)) {
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
$register->get('telemetry.connectionCounter')->add(-1);
$register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes'));
$subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection));
if ($subscriptionsBeforeClose > 0) {
$register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes'));
}
$projectId = $realtime->connections[$connection]['projectId'];

View file

@ -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: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
@ -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: <?php echo $organization; ?>/<?php echo $image; ?>:<?php echo $version."\n"; ?>
@ -965,7 +947,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_DB_ADAPTER
<?php if ($enableAssistant): ?>
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:
<?php elseif ($dbService === 'mongodb'): ?>
appwrite-mongodb:
appwrite-mongodb-keyfile:
appwrite-mongodb-config:
<?php endif; ?>
appwrite-redis:
appwrite-cache:

View file

@ -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.*",

157
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f6a87c1012b316e614258f8f57a28e48",
"content-hash": "c5ae97637fd0ec0a950044d1c33677ea",
"packages": [
{
"name": "adhocore/jwt",
@ -2887,7 +2887,7 @@
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.34.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@ -2948,7 +2948,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0"
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0"
},
"funding": [
{
@ -2972,7 +2972,7 @@
},
{
"name": "symfony/polyfill-php82",
"version": "v1.34.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
@ -3028,7 +3028,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0"
"source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0"
},
"funding": [
{
@ -3052,7 +3052,7 @@
},
{
"name": "symfony/polyfill-php83",
"version": "v1.34.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
@ -3108,7 +3108,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0"
"source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0"
},
"funding": [
{
@ -3132,7 +3132,7 @@
},
{
"name": "symfony/polyfill-php85",
"version": "v1.34.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
@ -3188,7 +3188,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0"
"source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0"
},
"funding": [
{
@ -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",
@ -7778,7 +7779,7 @@
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.34.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@ -7837,7 +7838,7 @@
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0"
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0"
},
"funding": [
{
@ -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": [
{
@ -8028,7 +8029,7 @@
},
{
"name": "symfony/polyfill-php81",
"version": "v1.34.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
@ -8084,7 +8085,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0"
"source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0"
},
"funding": [
{

View file

@ -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

View file

@ -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.

View file

@ -1 +0,0 @@
Get a custom SMS template for the specified locale and type returning it's contents.

View file

@ -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.

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -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');

View file

@ -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

View file

@ -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();

View file

@ -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));
}

View file

@ -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')

View file

@ -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(),

View file

@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status;
namespace Appwrite\Platform\Modules\Project\Http\Project\Protocols;
use Appwrite\Event\Event;
use Appwrite\Platform\Action;
@ -22,16 +22,17 @@ class Update extends Action
public static function getName()
{
return 'updateProjectProtocolStatus';
return 'updateProjectProtocol';
}
public function __construct()
{
$this
->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.[protocolId].update')
@ -40,9 +41,9 @@ class Update extends Action
->label('sdk', new Method(
namespace: 'project',
group: null,
name: 'updateProtocolStatus',
name: 'updateProtocol',
description: <<<EOT
Update the status of a specific protocol. Use this endpoint to enable or disable a protocol in your project.
Update properties of a specific protocol. Use this endpoint to enable or disable a protocol in your project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [

View file

@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Services\Status;
namespace Appwrite\Platform\Modules\Project\Http\Project\Services;
use Appwrite\Event\Event;
use Appwrite\Platform\Action;
@ -22,16 +22,17 @@ class Update extends Action
public static function getName()
{
return 'updateProjectServiceStatus';
return 'updateProjectService';
}
public function __construct()
{
$this
->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.[serviceId].update')
@ -40,9 +41,9 @@ class Update extends Action
->label('sdk', new Method(
namespace: 'project',
group: null,
name: 'updateServiceStatus',
name: 'updateService',
description: <<<EOT
Update the status of a specific service. Use this endpoint to enable or disable a service in your project.
Update properties of a specific service. Use this endpoint to enable or disable a service in your project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [

View file

@ -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());

View file

@ -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
}
}
}

View file

@ -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',

View file

@ -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();

View file

@ -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);

View file

@ -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');
}

View file

@ -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

View file

@ -195,9 +195,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);
@ -233,7 +249,7 @@ class Migrations extends Action
$credentials['endpoint'],
$credentials['apiKey'],
$getDatabasesDB,
SourceAppwrite::SOURCE_DATABASE,
$useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE,
$projectDB,
$queries
),
@ -578,9 +594,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);
}

View file

@ -771,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':

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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':

View file

@ -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

View file

@ -0,0 +1,29 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
class ProviderRepositoryFrameworkList extends BaseList
{
public array $conditions = [
'type' => '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',
]);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
class ProviderRepositoryRuntimeList extends BaseList
{
public array $conditions = [
'type' => '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',
]);
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
class TemplateSMS extends Template
{
public function __construct()
{
parent::__construct();
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'SmsTemplate';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_SMS_TEMPLATE;
}
}

View file

@ -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,
]);
}

View file

@ -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,
]);
}

View file

@ -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

View file

@ -164,6 +164,20 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope
return $response;
}
/**
* @param array<int, array<string, mixed>> $payloadEntries
* @return array<string, mixed>
*/
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();

View file

@ -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

View file

@ -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([