Compare commits

..
Author SHA1 Message Date
ArnabChatterjee20k 737c85822d multi line comment 2026-05-04 12:46:44 +05:30
ArnabChatterjee20k 1fc3a8803c multiline comment 2026-05-04 12:45:32 +05:30
ArnabChatterjee20k c32294743a reverted e2e 2026-05-04 12:31:50 +05:30
ArnabChatterjee20k f5a7cfd2ea fix: resolve query syntax errors and improve error handling in Request class 2026-05-04 12:22:48 +05:30
Luke B. SilverandGitHub 76e6239d32 Merge pull request #12204 from appwrite/chore/bump-image-1.4.1
chore: bump base image to 1.4.1
2026-05-03 20:18:03 +01:00
Matej BačoandGitHub 1da5b549af Merge pull request #12203 from appwrite/fix-missing-scopes-console
Fix: Add deprecated function scopes
2026-05-03 20:21:28 +02:00
loks0nandClaude Opus 4.7 92eceba218 chore: bump base image to 1.4.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:21:00 +01:00
8f68a59a79 Apply suggestions from code review
Co-authored-by: Matej Bačo <matejbaco2000@gmail.com>
2026-05-03 19:51:56 +02:00
6051b8150c Apply suggestions from code review
Co-authored-by: Matej Bačo <matejbaco2000@gmail.com>
2026-05-03 19:51:10 +02:00
Matej Bačo 4e20e382d2 Add deprecated function scopes 2026-05-03 19:49:13 +02:00
Luke B. SilverandGitHub c96922422f Merge pull request #12201 from appwrite/chore/bump-image-1.3.1
chore: bump base image to 1.3.1
2026-05-03 15:27:45 +01:00
loks0nandClaude Opus 4.7 7f27851dab chore: bump base image to 1.3.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:15:27 +01:00
Chirag AggarwalandGitHub f396064c49 Merge pull request #12198 from appwrite/add-rust-starter-function-template 2026-05-02 16:53:41 +05:30
Chirag Aggarwal 413930a15e Add Rust starter function template runtime 2026-05-02 12:19:07 +05:30
Chirag AggarwalandGitHub f672891c42 Merge pull request #12196 from appwrite/chore/update-runtimes-0.20 2026-05-01 19:33:01 +05:30
Chirag Aggarwal 65f926b4c5 Update appwrite runtimes to 0.20 2026-05-01 18:55:13 +05:30
Matej BačoandGitHub 08ad7d7f71 Merge pull request #12192 from appwrite/fix-oauth-bugs
Fix: OAuth UX with required params
2026-04-30 13:07:52 +02:00
Matej Bačo 71300383b2 Update tests 2026-04-30 12:48:59 +02:00
Matej Bačo 8785aa9877 Fix nullable implementation oauth 2026-04-30 12:41:55 +02:00
Matej Bačo 62b7d5558f Mark params nonrequired 2026-04-30 12:36:45 +02:00
Shmuel FogelandGitHub fbbab0f7e1 Merge pull request #12181 from appwrite/set-global-collection
Set global collection
2026-04-30 12:49:00 +03:00
Matej BačoandGitHub 86b9599a57 Merge pull request #12191 from appwrite/oauth-quality-improvements
Fix: oauth order; Fix apple secreting too much
2026-04-30 11:17:51 +02:00
Luke B. SilverandGitHub 526b390c15 Merge pull request #12189 from FerRubioMorales/fix/spanish-session-alert-translations
fix(locale): add Spanish session alert translations
2026-04-30 10:08:02 +01:00
Matej Bačo b73ba68bfb Fix oauth order; Fix apple secreting too much 2026-04-30 10:21:38 +02:00
fogelito d98bd8c972 Remove line 2026-04-30 10:44:21 +03:00
fogelito 81c580bf50 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into set-global-collection 2026-04-30 10:42:09 +03:00
fogelito c0bba74eee set setGlobalCollections logs 2026-04-30 10:36:12 +03:00
frubio 337d47b1d9 fix(locale): add Spanish session alert translations 2026-04-30 09:33:08 +02:00
fogelito d099167d18 Shared env 2026-04-29 17:09:25 +03:00
fogelito 9d3255f5cd Update lock 2026-04-29 17:08:14 +03:00
fogelito f03cc847f8 Merge branch '1.9.x' of https://github.com/appwrite/appwrite into set-global-collection
# Conflicts:
#	composer.lock
2026-04-29 17:07:32 +03:00
fogelito 18b9769672 lock file 2026-04-29 16:14:21 +03:00
fogelito 8eed06678b formatting 2026-04-29 15:54:05 +03:00
fogelito 7a9a2899ff setGlobalCollections 2026-04-29 15:41:56 +03:00
36 changed files with 517 additions and 803 deletions
+2
View File
@@ -47,6 +47,8 @@ _APP_DB_SCHEMA=appwrite
_APP_DB_USER=user
_APP_DB_PASS=password
_APP_DB_ROOT_PASS=rootsecretpassword
_APP_DATABASE_SHARED_TABLES=
_APP_DATABASE_SHARED_NAMESPACE=
_APP_DB_ADAPTER_DOCUMENTSDB=mongodb
_APP_DB_HOST_DOCUMENTSDB=mongodb
_APP_DB_PORT_DOCUMENTSDB=27017
+1 -1
View File
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
FROM appwrite/base:1.2.1 AS base
FROM appwrite/base:1.4.1 AS base
LABEL maintainer="team@appwrite.io"
+20
View File
@@ -157,12 +157,19 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
}
if (isset($databases[$dsn->getHost()])) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database = $databases[$dsn->getHost()];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -182,9 +189,16 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -212,6 +226,11 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
return $database;
}
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -220,6 +239,7 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
->setAuthorization($authorization)
->setSharedTables(true)
->setNamespace('logsV1')
->setGlobalCollections($logsCollections)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
-5
View File
@@ -54,11 +54,6 @@ return [
'description' => 'Rate limit for the current endpoint has been exceeded. Please try again after some time.',
'code' => 429,
],
Exception::GENERAL_RESOURCE_LOCKED => [
'name' => Exception::GENERAL_RESOURCE_LOCKED,
'description' => 'The requested resource is currently being modified by another request. Please retry after a brief delay.',
'code' => 409,
],
Exception::GENERAL_SMTP_DISABLED => [
'name' => Exception::GENERAL_SMTP_DISABLED,
'description' => 'SMTP is disabled on your Appwrite instance. You can <a href="/docs/email-delivery">learn more about setting up SMTP</a> in our docs.',
+10
View File
@@ -28,6 +28,16 @@
"emails.invitation.thanks": "Gracias.,",
"emails.invitation.buttonText": "Aceptar invitación a {{team}}",
"emails.invitation.signature": "El equipo de {{project}}",
"emails.sessionAlert.subject": "Alerta de seguridad: nueva sesión en tu cuenta de {{project}}",
"emails.sessionAlert.preview": "Nuevo inicio de sesión detectado en {{project}} a las {{time}} UTC.",
"emails.sessionAlert.hello": "Hola {{user}},",
"emails.sessionAlert.body": "Se ha creado una nueva sesión en tu cuenta de {{b}}{{project}}{{/b}}, {{b}}el {{date}} de {{year}} a las {{time}} UTC{{/b}}.\nEstos son los detalles de la nueva sesión:",
"emails.sessionAlert.listDevice": "Dispositivo: {{b}}{{device}}{{/b}}",
"emails.sessionAlert.listIpAddress": "Dirección IP: {{b}}{{ipAddress}}{{/b}}",
"emails.sessionAlert.listCountry": "País: {{b}}{{country}}{{/b}}",
"emails.sessionAlert.footer": "Si has sido tú, no tienes que hacer nada más.\nSi no has iniciado esta sesión o sospechas actividad no autorizada, protege tu cuenta.",
"emails.sessionAlert.thanks": "Gracias,",
"emails.sessionAlert.signature": "El equipo de {{project}}",
"locale.country.unknown": "Desconocido",
"countries.af": "Afganistán",
"countries.ao": "Angola",
+10
View File
@@ -215,6 +215,16 @@ return [
'description' => 'Access to create function executions',
'category' => 'Functions',
],
'execution.read' => [
'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.',
'category' => 'Functions',
'deprecated' => true,
],
'execution.write' => [
'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.',
'category' => 'Functions',
'deprecated' => true,
],
// Sites
'sites.read' => [
+3 -2
View File
@@ -79,12 +79,13 @@ return [
...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList),
...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList),
...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList),
...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList),
],
'instructions' => 'For documentation and instructions check out <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates/tree/main/node/starter">file</a>.',
'instructions' => 'For documentation and instructions check out the <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates">templates repository</a>.',
'vcsProvider' => 'github',
'providerRepositoryId' => 'templates',
'providerOwner' => 'appwrite',
'providerVersion' => '0.2.*',
'providerVersion' => '0.3.*',
'variables' => [],
'scopes' => ['users.read']
],
-9
View File
@@ -34,15 +34,6 @@ return [
'question' => '',
'filter' => ''
],
[
'name' => '_APP_LOCKING_ENABLED',
'description' => 'Enable distributed locking for platform writes. Locks coordinate concurrent updates across API pods so that read-modify-write operations on shared documents do not lose updates. By default, set to \'enabled\'. Set to \'disabled\' as an emergency kill switch — locks become no-ops (fail-open) and concurrent writes will race.',
'introduction' => '1.9.3',
'default' => 'enabled',
'required' => false,
'question' => '',
'filter' => ''
],
[
'name' => '_APP_OPTIONS_FORCE_HTTPS',
'description' => 'Allows you to force HTTPS connection to your API. This feature redirects any HTTP call to HTTPS and adds the \'Strict-Transport-Security\' header to all HTTP responses. By default, set to \'enabled\'. To disable, set to \'disabled\'. This feature will work only when your ports are set to default 80 and 443, and you have set up wildcard certificates with DNS challenge.',
+12 -15
View File
@@ -11,7 +11,6 @@ use Appwrite\Event\Delete as DeleteEvent;
use Appwrite\Event\Event;
use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Locking\Lock;
use Appwrite\Network\Cors;
use Appwrite\Platform\Appwrite;
use Appwrite\SDK\Method;
@@ -70,7 +69,7 @@ use Utopia\Validator\Text;
Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE);
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock)
function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount)
{
$host = $request->getHostname();
if (!empty($previewHostname)) {
@@ -139,7 +138,9 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
if (!$project->isEmpty() && $project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
$lock->set('projects', $project->getId(), 'accessedAt', DateTime::now());
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
'accessedAt' => DateTime::now()
])));
}
/**
@@ -848,8 +849,7 @@ Http::init()
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->inject('lock')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, Event $queueForEvents, Bus $bus, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
@@ -857,7 +857,7 @@ Http::init()
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1148,15 +1148,14 @@ Http::options()
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->inject('lock')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
/*
* Appwrite Router
*/
$platformHostnames = $platform['hostnames'] ?? [];
// Only run Router when external domain
if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1551,14 +1550,13 @@ Http::get('/robots.txt')
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->inject('lock')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/robots.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
@@ -1586,14 +1584,13 @@ Http::get('/humans.txt')
->inject('authorization')
->inject('queueForDeletes')
->inject('executionsRetentionCount')
->inject('lock')
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount, Lock $lock) {
->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) {
$platformHostnames = $platform['hostnames'] ?? [];
if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) {
$template = new View(__DIR__ . '/../views/general/humans.phtml');
$response->text($template->render(false));
} else {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount, $lock)) {
if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $bus, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) {
$utopia->getRoute()?->label('router', true);
}
}
+16 -16
View File
@@ -20,7 +20,6 @@ use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Functions\EventProcessor;
use Appwrite\Locking\Lock;
use Appwrite\SDK\Method;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
@@ -101,8 +100,7 @@ Http::init()
->inject('team')
->inject('apiKey')
->inject('authorization')
->inject('lock')
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization, Lock $lock) {
->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, AuditContext $auditContext, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) {
$route = $utopia->getRoute();
if ($route === null) {
throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND);
@@ -247,22 +245,20 @@ Http::init()
$sdks[] = $sdk;
$updates->setAttribute('sdks', $sdks);
$updates->setAttribute('accessedAt', DateTime::now());
$updates->setAttribute('accessedAt', Datetime::now());
}
}
if (! $updates->isEmpty()) {
$lock->run('keys', $dbKey->getId(), function () use ($dbForPlatform, $dbKey, $updates, $apiKey, $project, $user, $team) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates));
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates));
if (! empty($apiKey->getProjectId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
} elseif (! empty($apiKey->getUserId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId()));
} elseif (! empty($apiKey->getTeamId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId()));
}
});
if (! empty($apiKey->getProjectId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId()));
} elseif (! empty($apiKey->getUserId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId()));
} elseif (! empty($apiKey->getTeamId())) {
$dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId()));
}
}
$userClone = clone $user;
@@ -393,7 +389,9 @@ Http::init()
if ($project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
$lock->set('projects', $project->getId(), 'accessedAt', DateTime::now());
$authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
'accessedAt' => DateTime::now()
])));
}
}
@@ -410,7 +408,9 @@ Http::init()
'accessedAt' => $user->getAttribute('accessedAt')
]));
} else {
$lock->set('users', $user->getId(), 'accessedAt', $user->getAttribute('accessedAt'));
$authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), new Document([
'accessedAt' => $user->getAttribute('accessedAt')
])));
}
}
}
+6
View File
@@ -159,10 +159,16 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+28 -6
View File
@@ -17,7 +17,6 @@ use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\GraphQL\Schema;
use Appwrite\Locking\Lock;
use Appwrite\Network\Cors;
use Appwrite\Network\Platform;
use Appwrite\Network\Validator\Origin;
@@ -50,7 +49,6 @@ use Utopia\DSN\DSN;
use Utopia\Http\Http;
use Utopia\Locale\Locale;
use Utopia\Logger\Log;
use Utopia\Logger\Logger;
use Utopia\Pools\Group;
use Utopia\Queue\Publisher;
use Utopia\Storage\Device;
@@ -75,10 +73,6 @@ return function (Container $container): void {
return $register->get('logger');
}, ['register']);
$container->set('lock', function (\Redis $redis, Telemetry $telemetry, Database $dbForPlatform, Authorization $authorization, Log $log, ?Logger $logger, Document $project): Lock {
return new Lock($redis, $telemetry, $dbForPlatform, $authorization, $log, $logger, $project);
}, ['redis', 'telemetry', 'dbForPlatform', 'authorization', 'log', 'logger', 'project']);
$container->set('authorization', function () {
return new Authorization();
}, []);
@@ -210,9 +204,16 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -229,6 +230,11 @@ return function (Container $container): void {
$adapter = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter ??= new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -236,6 +242,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
@@ -696,8 +703,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -1298,6 +1312,12 @@ return function (Container $container): void {
$database = new Database($adapter, $cache);
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
@@ -1320,6 +1340,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -1331,6 +1352,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
+38
View File
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Database\Documents\User;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
@@ -90,8 +91,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -130,8 +138,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -152,8 +167,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -210,6 +232,14 @@ return function (Container $container): void {
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database->setGlobalCollections($projectsGlobalCollections);
// For separate pools (documentsdb/vectorsdb), check their own shared tables config.
// If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
if ($databaseHost !== $dsn->getHost()) {
@@ -222,6 +252,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -233,6 +264,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -257,6 +289,11 @@ return function (Container $container): void {
return $database;
}
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -264,6 +301,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
+6
View File
@@ -130,8 +130,14 @@ if (!function_exists('getProjectDB')) {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
+2 -9
View File
@@ -49,7 +49,7 @@
"ext-openssl": "*",
"ext-zlib": "*",
"ext-sockets": "*",
"appwrite/php-runtimes": "0.19.*",
"appwrite/php-runtimes": "0.20.*",
"appwrite/php-clamav": "2.0.*",
"utopia-php/abuse": "1.2.*",
"utopia-php/agents": "1.2.*",
@@ -95,15 +95,8 @@
"spomky-labs/otphp": "11.*",
"webonyx/graphql-php": "15.31.*",
"league/csv": "9.14.*",
"enshrined/svg-sanitize": "0.22.*",
"utopia-php/lock": "0.2.*"
"enshrined/svg-sanitize": "0.22.*"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/utopia-php/lock"
}
],
"require-dev": {
"ext-fileinfo": "*",
"appwrite/sdk-generator": "*",
Generated
+9 -81
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": "a9d25a0518aee3fc29ea57498cb95744",
"content-hash": "bd45829c252971301370d62300be106d",
"packages": [
{
"name": "adhocore/jwt",
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
"version": "0.19.5",
"version": "0.20.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546"
"reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546",
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
"source": "https://github.com/appwrite/runtimes/tree/0.19.5"
"source": "https://github.com/appwrite/runtimes/tree/0.20.0"
},
"time": "2026-04-01T01:39:23+00:00"
"time": "2026-05-01T07:47:07+00:00"
},
{
"name": "brick/math",
@@ -4423,78 +4423,6 @@
},
"time": "2025-08-12T12:58:26+00:00"
},
{
"name": "utopia-php/lock",
"version": "0.2.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/lock.git",
"reference": "49317c9493d8f747e4299aa24c22862aa5f6e106"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/lock/zipball/49317c9493d8f747e4299aa24c22862aa5f6e106",
"reference": "49317c9493d8f747e4299aa24c22862aa5f6e106",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"laravel/pint": "1.*",
"phpstan/phpstan": "2.*",
"phpunit/phpunit": "11.*",
"swoole/ide-helper": "*"
},
"suggest": {
"ext-pcntl": "Required to run the File lock tests",
"ext-redis": "Required for the Distributed lock",
"ext-swoole": "Required for the Mutex and Semaphore locks (>=6.0)"
},
"type": "library",
"autoload": {
"psr-4": {
"Utopia\\Lock\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Utopia\\Lock\\Tests\\": "tests/"
}
},
"scripts": {
"test": [
"vendor/bin/phpunit"
],
"lint": [
"vendor/bin/pint --test"
],
"format": [
"vendor/bin/pint"
],
"format:check": [
"vendor/bin/pint --test"
],
"analyze": [
"vendor/bin/phpstan analyse --memory-limit=512M"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Appwrite Team",
"email": "team@appwrite.io"
}
],
"description": "Mutex, semaphore, file and distributed locks for PHP — one interface, four backends.",
"support": {
"source": "https://github.com/utopia-php/lock/tree/0.2.0",
"issues": "https://github.com/utopia-php/lock/issues"
},
"time": "2026-04-24T10:47:56+00:00"
},
{
"name": "utopia-php/logger",
"version": "0.6.2",
@@ -8516,7 +8444,7 @@
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {},
"stability-flags": [],
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -8537,5 +8465,5 @@
"platform-dev": {
"ext-fileinfo": "*"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
-1
View File
@@ -212,7 +212,6 @@ services:
- _APP_EXECUTOR_SECRET
- _APP_EXECUTOR_HOST
- _APP_LOGGING_CONFIG
- _APP_LOCKING_ENABLED
- _APP_MAINTENANCE_INTERVAL
- _APP_MAINTENANCE_RETENTION_EXECUTION
- _APP_MAINTENANCE_RETENTION_CACHE
+7
View File
@@ -37,6 +37,13 @@ class Authentik extends OAuth2
return 'authentik';
}
public function verifyCredentials(): void
{
if (empty($this->getAuthentikDomain())) {
throw new \Exception('Authentik endpoint is required.');
}
}
/**
* @return string
*/
+7
View File
@@ -37,6 +37,13 @@ class FusionAuth extends OAuth2
return 'fusionauth';
}
public function verifyCredentials(): void
{
if (empty($this->getFusionAuthDomain())) {
throw new \Exception('FusionAuth endpoint is required.');
}
}
/**
* @return string
*/
+11
View File
@@ -37,6 +37,17 @@ class Keycloak extends OAuth2
return 'keycloak';
}
public function verifyCredentials(): void
{
if (empty($this->getKeycloakDomain())) {
throw new \Exception('Keycloak endpoint is required.');
}
if (empty($this->getKeycloakRealm())) {
throw new \Exception('Keycloak realm name is required.');
}
}
/**
* @return string
*/
+9 -2
View File
@@ -36,6 +36,13 @@ class Microsoft extends OAuth2
return 'microsoft';
}
public function verifyCredentials(): void
{
if (empty($this->getTenantID())) {
throw new \Exception('Microsoft tenant is required.');
}
}
/**
* @return string
*/
@@ -201,7 +208,7 @@ class Microsoft extends OAuth2
}
/**
* Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback
* Extracts the Tenant Id from the JSON stored in appSecret.
*
* @return string
*/
@@ -209,6 +216,6 @@ class Microsoft extends OAuth2
{
$secret = $this->getAppSecret();
return $secret['tenantID'] ?? 'common';
return $secret['tenantID'] ?? '';
}
}
-1
View File
@@ -45,7 +45,6 @@ class Exception extends \Exception
public const string GENERAL_SERVICE_DISABLED = 'general_service_disabled';
public const string GENERAL_UNAUTHORIZED_SCOPE = 'general_unauthorized_scope';
public const string GENERAL_RATE_LIMIT_EXCEEDED = 'general_rate_limit_exceeded';
public const string GENERAL_RESOURCE_LOCKED = 'general_resource_locked';
public const string GENERAL_SMTP_DISABLED = 'general_smtp_disabled';
public const string GENERAL_PHONE_DISABLED = 'general_phone_disabled';
public const string GENERAL_ARGUMENT_INVALID = 'general_argument_invalid';
-235
View File
@@ -1,235 +0,0 @@
<?php
namespace Appwrite\Locking;
use Appwrite\Extend\Exception;
use Closure;
use Throwable;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Lock\Distributed as DistributedLock;
use Utopia\Logger\Log;
use Utopia\Logger\Logger;
use Utopia\System\System;
use Utopia\Telemetry\Adapter as Telemetry;
final class Lock
{
private const SKIP_TTL_SECONDS = 5;
private const FAIL_TTL_SECONDS = 10;
private const FAIL_WAIT_SECONDS = 3.0;
private const REPORT_RATE_LIMIT_SECONDS = 60;
private const OUTCOME_ACQUIRED = 'acquired';
private const OUTCOME_SKIPPED = 'skipped';
private const OUTCOME_CONTENDED = 'contended';
private const OUTCOME_BACKEND_ERROR = 'backend_error';
private const OUTCOME_RELEASE_ERROR = 'release_error';
private readonly bool $enabled;
private readonly mixed $attempts;
private readonly string $projectInternalId;
/** @var array<string,int> */
private static array $lastReportAt = [];
public function __construct(
private readonly \Redis $redis,
Telemetry $telemetry,
private readonly Database $dbForPlatform,
private readonly Authorization $authorization,
private readonly Log $log,
private readonly ?Logger $logger,
Document $project,
) {
$this->enabled = System::getEnv('_APP_LOCKING_ENABLED', 'enabled') !== 'disabled';
$this->attempts = $telemetry->createCounter('lock.attempts', null, 'Distributed lock acquire outcomes');
$sequence = $project->getSequence();
$this->projectInternalId = ($sequence !== null && $sequence !== '') ? (string) $sequence : 'unknown';
}
/**
* Throttled single-attribute write under a per-attribute skip-on-contention
* lock with authorization bypass. For idempotent timestamp-style updates
* (accessedAt, mcpAccessedAt) where regional pods writing the same value
* would thrash the platform DB.
*/
public function set(
string $collection,
string $id,
string $attribute,
string $value,
): void {
$key = "lock:platform:{$this->projectInternalId}:{$collection}:{$id}:{$attribute}";
$this->execute($key, $collection, function () use ($collection, $id, $attribute, $value) {
$this->authorization->skip(fn () => $this->dbForPlatform->updateDocument(
$collection,
$id,
new Document([$attribute => $value])
));
});
}
/**
* Skip-on-contention lock around an arbitrary callback for a platform
* document. For idempotent multi-statement writes that don't fit `set`.
*/
public function run(string $collection, string $id, Closure $fn): void
{
$key = "lock:platform:{$this->projectInternalId}:{$collection}:{$id}";
$this->execute($key, $collection, $fn);
}
/**
* Block-then-409 lock around an arbitrary callback for a platform document.
* For read-modify-write endpoints where silently dropping a concurrent
* request would lose user data.
*/
public function runOrFail(string $collection, string $id, Closure $fn): mixed
{
$key = "lock:platform:{$this->projectInternalId}:{$collection}:{$id}";
return $this->execute($key, $collection, $fn, ttl: self::FAIL_TTL_SECONDS, orFail: true);
}
/**
* Generic lock primitive with full control over key, TTL, contention
* behavior, and wait timeout. Escape hatch for non-platform keys
* (cache, queue, edge) and for unusual TTL/timeout requirements.
*
* Caller may pass `target` for telemetry; otherwise it's extracted by
* position from the key (best-effort for keys following the standard
* `lock:platform:{project}:{target}:...` shape).
*/
public function withKey(
string $key,
Closure $fn,
int $ttl = self::SKIP_TTL_SECONDS,
bool $orFail = false,
float $waitTimeout = self::FAIL_WAIT_SECONDS,
?string $target = null,
): mixed {
return $this->execute(
$key,
$target ?? self::inferTargetFromKey($key),
$fn,
ttl: $ttl,
orFail: $orFail,
waitTimeout: $waitTimeout,
);
}
private function execute(
string $key,
string $target,
Closure $fn,
int $ttl = self::SKIP_TTL_SECONDS,
bool $orFail = false,
float $waitTimeout = self::FAIL_WAIT_SECONDS,
): mixed {
if (! $this->enabled) {
return $fn();
}
$lock = new DistributedLock($this->redis, $key, $ttl);
$labels = ['target' => $target, 'project' => $this->projectInternalId];
try {
$acquired = $orFail ? $lock->acquire($waitTimeout) : $lock->tryAcquire();
} catch (\RedisException $e) {
$this->attempts->add(1, ['outcome' => self::OUTCOME_BACKEND_ERROR, ...$labels]);
$this->reportError(self::OUTCOME_BACKEND_ERROR, $key, $target, $e);
return $fn();
}
if (! $acquired) {
if ($orFail) {
$this->attempts->add(1, ['outcome' => self::OUTCOME_CONTENDED, ...$labels]);
// No custom message — the lock key embeds collection + document id.
throw new Exception(Exception::GENERAL_RESOURCE_LOCKED);
}
$this->attempts->add(1, ['outcome' => self::OUTCOME_SKIPPED, ...$labels]);
return null;
}
$this->attempts->add(1, ['outcome' => self::OUTCOME_ACQUIRED, ...$labels]);
try {
return $fn();
} finally {
try {
$lock->release();
} catch (Throwable $e) {
$this->attempts->add(1, ['outcome' => self::OUTCOME_RELEASE_ERROR, ...$labels]);
$this->reportError(self::OUTCOME_RELEASE_ERROR, $key, $target, $e);
}
}
}
/**
* Best-effort target extraction for telemetry. Assumes the standard
* `lock:platform:{project}:{target}:...` shape. For non-platform keys
* passed via withKey(), callers should pass `target` explicitly.
*/
private static function inferTargetFromKey(string $key): string
{
$parts = explode(':', $key, 5);
return $parts[3] ?? 'unknown';
}
/**
* Rate-limited to one push per REPORT_RATE_LIMIT_SECONDS per (action, target)
* so a sustained backend outage doesn't flood Sentry across the pod fleet.
*/
private function reportError(string $action, string $key, string $target, Throwable $e): void
{
Console::warning("Lock {$action} for {$key}: {$e->getMessage()}");
if ($this->logger === null) {
return;
}
$bucket = $action.':'.$target;
$now = time();
if ((self::$lastReportAt[$bucket] ?? 0) + self::REPORT_RATE_LIMIT_SECONDS > $now) {
return;
}
self::$lastReportAt[$bucket] = $now;
$this->log->setNamespace('http');
$this->log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname()));
$this->log->setVersion(APP_VERSION_STABLE);
$this->log->setType(Log::TYPE_WARNING);
$this->log->setMessage('Distributed lock '.$action.': '.$e->getMessage());
$this->log->setAction("lock.{$action}");
$this->log->setEnvironment(System::getEnv('_APP_ENV', 'development') === 'production'
? Log::ENVIRONMENT_PRODUCTION
: Log::ENVIRONMENT_STAGING);
$this->log->addTag('lock.target', $target);
$this->log->addTag('lock.project', $this->projectInternalId);
// Strip trailing document ID to keep aggregator cardinality bounded.
$this->log->addTag('lock.key_pattern', preg_replace('/:[^:]+$/', ':*', $key));
$this->log->addTag('code', $e->getCode());
$this->log->addExtra('file', $e->getFile());
$this->log->addExtra('line', $e->getLine());
$this->log->addExtra('trace', $e->getTraceAsString());
try {
$this->logger->addLog($this->log);
} catch (Throwable) {
}
}
}
@@ -54,9 +54,9 @@ class XList extends Action
$actions = OAuth2Base::getProviderActions();
$providers = [];
foreach ($actions as $providerId => $updateClass) {
$config = $providersConfig[$providerId] ?? null;
if ($config === null) {
foreach ($providersConfig as $providerId => $config) {
$updateClass = $actions[$providerId] ?? null;
if ($updateClass === null) {
continue;
}
if (!($config['enabled'] ?? false)) {
@@ -146,13 +146,14 @@ class Update extends Base
{
$providerId = static::getProviderId();
$oAuthProviders = $project->getAttribute('oAuthProviders', []);
$storedSecret = $this->decodeStoredSecret($project);
return new Document([
'$id' => $providerId,
'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
'keyId' => '',
'teamId' => '',
'keyId' => $storedSecret['keyID'] ?? '',
'teamId' => $storedSecret['teamID'] ?? '',
'p8File' => '',
]);
}
@@ -105,7 +105,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: false)
->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Authentik instance. For example: example.authentik.com', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -138,7 +138,7 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
string $endpoint,
?string $endpoint,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -151,7 +151,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}`
// to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()).
// The `endpoint` param is required on every call, so it's always written.
// The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -160,7 +160,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
'authentikDomain' => $endpoint,
'authentikDomain' => $endpoint ?? ($existing['authentikDomain'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
@@ -105,7 +105,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: false)
->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -138,7 +138,7 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
string $endpoint,
?string $endpoint,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -151,7 +151,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}`
// to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()).
// The `endpoint` param is required on every call, so it's always written.
// The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -160,7 +160,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
'fusionAuthDomain' => $endpoint,
'fusionAuthDomain' => $endpoint ?? ($existing['fusionAuthDomain'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
@@ -35,7 +35,7 @@ class Update extends Base
public static function getClientIdName(): string
{
return 'OAuth 2 app Client ID, or App ID';
return 'OAuth2 app Client ID, or App ID';
}
public static function getClientIdExample(): string
@@ -111,8 +111,8 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: false)
->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: false)
->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true)
->param('realmName', null, new Nullable(new Text(256, 0)), 'Keycloak realm name. For example: appwrite-realm', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -147,8 +147,8 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
string $endpoint,
string $realmName,
?string $endpoint,
?string $realmName,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -161,7 +161,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}`
// to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()).
// The `endpoint` and `realmName` params are required on every call, so they're always written.
// The `endpoint` and `realmName` params are optional; if omitted, existing stored values are preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -170,8 +170,8 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
'keycloakDomain' => $endpoint,
'keycloakRealm' => $realmName,
'keycloakDomain' => $endpoint ?? ($existing['keycloakDomain'] ?? ''),
'keycloakRealm' => $realmName ?? ($existing['keycloakRealm'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
@@ -115,7 +115,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', optional: false)
->param('tenant', null, new Nullable(new Text(256, 0)), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -148,7 +148,7 @@ class Update extends Base
public function handle(
?string $applicationId,
?string $applicationSecret,
string $tenant,
?string $tenant,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -161,7 +161,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}`
// to match the shape Microsoft's OAuth2 adapter expects (getTenantID()).
// The `tenant` param is required on every call, so it's always written.
// The `tenant` param is optional; if omitted, the existing stored tenant is preserved.
// `applicationSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -170,7 +170,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''),
'tenantID' => $tenant,
'tenantID' => $tenant ?? ($existing['tenantID'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled);
+39 -28
View File
@@ -51,38 +51,49 @@ class Request extends UtopiaRequest
if (!\is_array($methods)) {
$id = $methods->getNamespace() . '.' . $methods->getMethodName();
} else {
$matched = null;
foreach ($methods as $method) {
/** @var Method|null $method */
if ($method === null) {
continue;
}
// Find the method that matches the parameters passed
$methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters());
$invalidParams = \array_diff(\array_keys($parameters), $methodParamNames);
// No params defined, or all params are valid
if (empty($methodParamNames) || empty($invalidParams)) {
$matched = $method;
break;
}
}
$id = $matched !== null
? $matched->getNamespace() . '.' . $matched->getMethodName()
: 'unknown.unknown';
}
try {
foreach ($this->getFilters() as $filter) {
$parameters = $filter->parse($parameters, $id);
}
$this->filteredParams = $parameters;
return $parameters;
}
$matched = null;
foreach ($methods as $method) {
/** @var Method|null $method */
if ($method === null) {
continue;
} catch (\Throwable $e) {
/*
* 4xx filter throws are user-input errors that the action layer
* revalidates and reports. Cache the raw, pre-filter parameters
* so a subsequent getParams() e.g. when the framework builds
* arguments for an error hook returns without re-running
* filters. Otherwise the second throw gets wrapped as
* "Error handler had an error: ..." (HTTP 500), masking the
* intended 400.
*/
$code = $e->getCode();
if (\is_int($code) && $code >= 400 && $code < 500) {
$this->filteredParams = $parameters;
}
// Find the method that matches the parameters passed
$methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters());
$invalidParams = \array_diff(\array_keys($parameters), $methodParamNames);
// No params defined, or all params are valid
if (empty($methodParamNames) || empty($invalidParams)) {
$matched = $method;
break;
}
}
$id = $matched !== null
? $matched->getNamespace() . '.' . $matched->getMethodName()
: 'unknown.unknown';
// Apply filters
foreach ($this->getFilters() as $filter) {
$parameters = $filter->parse($parameters, $id);
throw $e;
}
$this->filteredParams = $parameters;
@@ -56,6 +56,8 @@ class ConsoleConsoleClientTest extends Scope
$this->assertEquals($response['body']['total'], \count($response['body']['oAuth2Providers']));
$providerIds = \array_column($response['body']['oAuth2Providers'], '$id');
$this->assertEquals('amazon', $providerIds[0]);
$this->assertEquals('zoom', $providerIds[\count($providerIds) - 1]);
// Well-known providers must be present
$this->assertContains('github', $providerIds);
@@ -99,7 +101,7 @@ class ConsoleConsoleClientTest extends Scope
$this->assertCount(2, $github['parameters']);
$clientId = $github['parameters'][0];
$this->assertEquals('clientId', $clientId['$id']);
$this->assertEquals('OAuth 2 app Client ID, or App ID', $clientId['name']);
$this->assertEquals('OAuth2 app Client ID, or App ID', $clientId['name']);
$this->assertEquals('e4d87900000000540733', $clientId['example']);
$this->assertEquals('Example of wrong value: 370006', $clientId['hint']);
$clientSecret = $github['parameters'][1];
+123 -109
View File
@@ -478,9 +478,8 @@ trait OAuth2Base
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('apple', $response['body']['$id']);
$this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']);
// keyId / teamId / p8File are write-only — PATCH response must not echo them back.
$this->assertSame('', $response['body']['keyId']);
$this->assertSame('', $response['body']['teamId']);
$this->assertSame('P4000000N8', $response['body']['keyId']);
$this->assertSame('D4000000R6', $response['body']['teamId']);
$this->assertSame('', $response['body']['p8File']);
$this->assertSame(false, $response['body']['enabled']);
@@ -511,12 +510,10 @@ trait OAuth2Base
]);
$this->assertSame(200, $response['headers']['status-code']);
// serviceId is the (non-secret) clientId; keyId/teamId are write-only
// and must not surface in the response. Persistence of the merged
// values is verified separately via the enable-after-merge tests.
$this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']);
$this->assertSame('', $response['body']['keyId']);
$this->assertSame('', $response['body']['teamId']);
$this->assertSame('KEYUPDATED', $response['body']['keyId']);
$this->assertSame('TEAMSEED01', $response['body']['teamId']);
$this->assertSame('', $response['body']['p8File']);
// Cleanup
$this->updateOAuth2('apple', [
@@ -546,9 +543,9 @@ trait OAuth2Base
'teamId' => 'TEAMROTATED',
]);
$this->assertSame(200, $teamOnly['headers']['status-code']);
// teamId is write-only; verify only the non-secret serviceId echo.
// The actual merge is validated by the enable-after-merge call below.
$this->assertSame('', $teamOnly['body']['teamId']);
$this->assertSame('TEAMROTATED', $teamOnly['body']['teamId']);
$this->assertSame('KEYMERGE01', $teamOnly['body']['keyId']);
$this->assertSame('', $teamOnly['body']['p8File']);
$this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']);
// Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob
@@ -669,9 +666,8 @@ trait OAuth2Base
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('ip.appwrite.app.read', $response['body']['serviceId']);
// All three secret-bearing fields must be hidden on read.
$this->assertSame('', $response['body']['keyId']);
$this->assertSame('', $response['body']['teamId']);
$this->assertSame('KEYREAD', $response['body']['keyId']);
$this->assertSame('TEAMREAD', $response['body']['teamId']);
$this->assertSame('', $response['body']['p8File']);
// Cleanup
@@ -699,13 +695,13 @@ trait OAuth2Base
$this->assertSame(200, $update['headers']['status-code']);
$this->assertTrue($update['body']['enabled']);
// GET must hide all three secret-bearing fields while keeping serviceId.
// GET must hide p8File while keeping the non-secret fields.
$get = $this->getOAuth2Provider('apple');
$this->assertSame(200, $get['headers']['status-code']);
$this->assertTrue($get['body']['enabled']);
$this->assertSame('ip.appwrite.app.enable', $get['body']['serviceId']);
$this->assertSame('', $get['body']['keyId']);
$this->assertSame('', $get['body']['teamId']);
$this->assertSame('ENABLEKEY', $get['body']['keyId']);
$this->assertSame('ENABLETEAM', $get['body']['teamId']);
$this->assertSame('', $get['body']['p8File']);
// Cleanup
@@ -876,30 +872,36 @@ trait OAuth2Base
}
// =========================================================================
// Update Authentik (clientId + clientSecret + REQUIRED endpoint)
// Update Authentik (clientId + clientSecret + optional endpoint)
// =========================================================================
public function testUpdateOAuth2AuthentikRequiresEndpoint(): void
public function testUpdateOAuth2AuthentikAllowsOmittedEndpointWhenDisabled(): void
{
// The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('authentik', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('authentik', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2AuthentikEmptyEndpointRejected(): void
public function testUpdateOAuth2AuthentikEmptyEndpointRejectedWhenEnabling(): void
{
// The `endpoint` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting — the validator should treat the
// empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('authentik', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -924,15 +926,14 @@ trait OAuth2Base
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.authentik.com',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2AuthentikPartialPreservesSecret(): void
{
// Authentik's `endpoint` is required on every call, so we always
// re-send it. The `clientSecret` lives in the JSON blob and must
// The `clientSecret` and `endpoint` live in the JSON blob and must
// survive when omitted on a subsequent call that only changes clientId.
$this->updateOAuth2('authentik', [
'clientId' => 'authentik-merge-client',
@@ -943,27 +944,24 @@ trait OAuth2Base
$response = $this->updateOAuth2('authentik', [
'clientId' => 'authentik-rotated-client',
'endpoint' => 'merge.authentik.com',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('authentik-rotated-client', $response['body']['clientId']);
$this->assertSame('merge.authentik.com', $response['body']['endpoint']);
// Confirm clientSecret survived the omitted-field merge by enabling
// — Authentik has no verifyCredentials() hook, so non-empty stored
// secret is enough. `endpoint` must be re-sent (required on enable too).
// without re-sending endpoint.
$enable = $this->updateOAuth2('authentik', [
'endpoint' => 'merge.authentik.com',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
// Cleanup — endpoint is required, use a placeholder.
// Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.authentik.com',
'endpoint' => '',
'enabled' => false,
]);
}
@@ -988,40 +986,46 @@ trait OAuth2Base
$this->assertSame('enable.authentik.com', $get['body']['endpoint']);
$this->assertSame('', $get['body']['clientSecret']);
// Cleanup — endpoint is required (Text(min=1)) so use a placeholder.
// Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.authentik.com',
'endpoint' => '',
'enabled' => false,
]);
}
// =========================================================================
// Update FusionAuth (clientId + clientSecret + REQUIRED endpoint)
// Update FusionAuth (clientId + clientSecret + optional endpoint)
// =========================================================================
public function testUpdateOAuth2FusionAuthRequiresEndpoint(): void
public function testUpdateOAuth2FusionAuthAllowsOmittedEndpointWhenDisabled(): void
{
// The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('fusionauth', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2FusionAuthEmptyEndpointRejected(): void
public function testUpdateOAuth2FusionAuthEmptyEndpointRejectedWhenEnabling(): void
{
// The `endpoint` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting — the validator should treat the
// empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1046,15 +1050,14 @@ trait OAuth2Base
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.fusionauth.io',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2FusionAuthPartialPreservesSecret(): void
{
// FusionAuth's `endpoint` is required on every call, so we always
// re-send it. The `clientSecret` lives in the JSON blob and must
// The `clientSecret` and `endpoint` live in the JSON blob and must
// survive when omitted on a subsequent call that only changes clientId.
$this->updateOAuth2('fusionauth', [
'clientId' => 'fusionauth-merge-client',
@@ -1065,27 +1068,24 @@ trait OAuth2Base
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'fusionauth-rotated-client',
'endpoint' => 'merge.fusionauth.io',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('fusionauth-rotated-client', $response['body']['clientId']);
$this->assertSame('merge.fusionauth.io', $response['body']['endpoint']);
// Confirm clientSecret survived the omitted-field merge by enabling
// — FusionAuth has no verifyCredentials() hook, so non-empty stored
// secret is enough. `endpoint` must be re-sent (required on enable too).
// without re-sending endpoint.
$enable = $this->updateOAuth2('fusionauth', [
'endpoint' => 'merge.fusionauth.io',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
// Cleanup — endpoint is required, use a placeholder.
// Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.fusionauth.io',
'endpoint' => '',
'enabled' => false,
]);
}
@@ -1110,70 +1110,85 @@ trait OAuth2Base
$this->assertSame('enable.fusionauth.io', $get['body']['endpoint']);
$this->assertSame('', $get['body']['clientSecret']);
// Cleanup — endpoint is required (Text(min=1)) so use a placeholder.
// Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.fusionauth.io',
'endpoint' => '',
'enabled' => false,
]);
}
// =========================================================================
// Update Keycloak (clientId + clientSecret + REQUIRED endpoint + REQUIRED realmName)
// Update Keycloak (clientId + clientSecret + optional endpoint + optional realmName)
// =========================================================================
public function testUpdateOAuth2KeycloakRequiresEndpoint(): void
public function testUpdateOAuth2KeycloakAllowsOmittedEndpointWhenDisabled(): void
{
// The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'realmName' => 'appwrite-realm',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('keycloak', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2KeycloakEmptyEndpointRejected(): void
public function testUpdateOAuth2KeycloakEmptyEndpointRejectedWhenEnabling(): void
{
// The `endpoint` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting — the validator should treat the
// empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
'realmName' => 'appwrite-realm',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
}
public function testUpdateOAuth2KeycloakRequiresRealmName(): void
public function testUpdateOAuth2KeycloakAllowsOmittedRealmNameWhenDisabled(): void
{
// The `realmName` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => 'keycloak.example.com',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('keycloak', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2KeycloakEmptyRealmNameRejected(): void
public function testUpdateOAuth2KeycloakEmptyRealmNameRejectedWhenEnabling(): void
{
// The `realmName` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => 'keycloak.example.com',
'realmName' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1200,16 +1215,15 @@ trait OAuth2Base
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.keycloak.com',
'realmName' => 'cleanup-realm',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2KeycloakPartialPreservesSecret(): void
{
// Keycloak's `endpoint` and `realmName` are required on every call,
// so we always re-send them. The `clientSecret` lives in the JSON
// The `clientSecret`, `endpoint`, and `realmName` live in the JSON
// blob and must survive when omitted on a subsequent call that only
// changes clientId.
$this->updateOAuth2('keycloak', [
@@ -1222,8 +1236,6 @@ trait OAuth2Base
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'keycloak-rotated-client',
'endpoint' => 'merge.keycloak.com',
'realmName' => 'merge-realm',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('keycloak-rotated-client', $response['body']['clientId']);
@@ -1231,23 +1243,19 @@ trait OAuth2Base
$this->assertSame('merge-realm', $response['body']['realmName']);
// Confirm clientSecret survived the omitted-field merge by enabling
// — Keycloak has no verifyCredentials() hook, so non-empty stored
// secret is enough. `endpoint`/`realmName` must be re-sent (required
// on enable too).
// without re-sending endpoint or realmName.
$enable = $this->updateOAuth2('keycloak', [
'endpoint' => 'merge.keycloak.com',
'realmName' => 'merge-realm',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
// Cleanup — endpoint and realmName are required, use placeholders.
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.keycloak.com',
'realmName' => 'cleanup-realm',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
@@ -1274,40 +1282,47 @@ trait OAuth2Base
$this->assertSame('enable-realm', $get['body']['realmName']);
$this->assertSame('', $get['body']['clientSecret']);
// Cleanup — endpoint and realmName are required (Text(min=1)) so use placeholders.
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.keycloak.com',
'realmName' => 'cleanup-realm',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
// =========================================================================
// Update Microsoft (applicationId + applicationSecret + REQUIRED tenant)
// Update Microsoft (applicationId + applicationSecret + optional tenant)
// =========================================================================
public function testUpdateOAuth2MicrosoftRequiresTenant(): void
public function testUpdateOAuth2MicrosoftAllowsOmittedTenantWhenDisabled(): void
{
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'whatever',
'applicationSecret' => 'whatever',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('microsoft', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2MicrosoftEmptyTenantRejected(): void
public function testUpdateOAuth2MicrosoftEmptyTenantRejectedWhenEnabling(): void
{
// The `tenant` validator is Text(min=1). Sending `''` must be rejected
// the same way as omitting — the validator should treat the empty
// string as a missing required field.
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'whatever',
'applicationSecret' => 'whatever',
'tenant' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1335,7 +1350,7 @@ trait OAuth2Base
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => 'common',
'tenant' => '',
'enabled' => false,
]);
}
@@ -1350,23 +1365,21 @@ trait OAuth2Base
'enabled' => false,
]);
// Patch with only `tenant` (it's required on every call) and a new
// applicationId, leaving applicationSecret omitted. The stored secret
// must not be wiped.
// Patch with only a new applicationId, leaving applicationSecret and
// tenant omitted. The stored JSON values must not be wiped.
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'updated-app-id',
'tenant' => 'organizations',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('updated-app-id', $response['body']['applicationId']);
$this->assertSame('organizations', $response['body']['tenant']);
$this->assertSame('common', $response['body']['tenant']);
// Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => 'common',
'tenant' => '',
'enabled' => false,
]);
}
@@ -1391,11 +1404,11 @@ trait OAuth2Base
$this->assertSame('common', $get['body']['tenant']);
$this->assertSame('', $get['body']['applicationSecret']);
// Cleanup — tenant is required (Text(min=1)) so use a placeholder.
// Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => 'common',
'tenant' => '',
'enabled' => false,
]);
}
@@ -2405,8 +2418,9 @@ trait OAuth2Base
//
// Ensures each provider's Update endpoint is wired up correctly: routing,
// provider class, response model and `$id`. Custom-shaped providers
// (apple, auth0, authentik, gitlab, microsoft, oidc, okta, dropbox) and
// sandboxes (paypalSandbox, tradeshiftSandbox) have dedicated tests above.
// (apple, auth0, authentik, fusionauth, gitlab, keycloak, microsoft, oidc,
// okta, dropbox) and sandboxes (paypalSandbox, tradeshiftSandbox) have
// dedicated tests above.
// Github is excluded because its `verifyCredentials()` hook is exercised
// separately.
// =========================================================================
-257
View File
@@ -1,257 +0,0 @@
<?php
namespace Tests\Unit\Locking;
use Appwrite\Extend\Exception;
use Appwrite\Locking\Lock;
use PHPUnit\Framework\TestCase;
use Redis;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Logger\Log;
use Utopia\Telemetry\Adapter\None as NoTelemetry;
class LockTest extends TestCase
{
private Redis $redis;
private Document $project;
private Authorization $authorization;
private Log $log;
/**
* Project sequence used for every test; lock keys are scoped under it
* so cleanup is bounded.
*/
private const PROJECT_SEQUENCE = '42';
private const KEY_PREFIX = 'lock:platform:'.self::PROJECT_SEQUENCE.':';
protected function setUp(): void
{
$host = \getenv('_APP_REDIS_HOST') ?: 'redis';
$port = (int) (\getenv('_APP_REDIS_PORT') ?: 6379);
$this->redis = new Redis();
$this->redis->connect($host, $port, 1.0);
$this->project = new Document([
'$id' => 'test-project',
'$sequence' => self::PROJECT_SEQUENCE,
]);
$this->authorization = new Authorization();
$this->log = new Log();
$this->cleanupKeys();
}
protected function tearDown(): void
{
if (isset($this->redis) && $this->redis->isConnected()) {
$this->cleanupKeys();
}
}
private function cleanupKeys(): void
{
foreach ($this->redis->keys(self::KEY_PREFIX.'*') as $key) {
$this->redis->del($key);
}
// also clean keys produced by withKey() tests
foreach ($this->redis->keys('lock:test:*') as $key) {
$this->redis->del($key);
}
}
private function makeLock(?Database $db = null, ?Authorization $auth = null): Lock
{
return new Lock(
$this->redis,
new NoTelemetry(),
$db ?? $this->createStub(Database::class),
$auth ?? $this->authorization,
$this->log,
null,
$this->project,
);
}
public function test_set_uses_per_attribute_key_and_auth_skipped_update(): void
{
$captured = null;
$db = $this->createMock(Database::class);
$db->expects($this->once())
->method('updateDocument')
->with('projects', 'p1', $this->callback(function (Document $doc) use (&$captured) {
$captured = $doc->getArrayCopy();
return true;
}))
->willReturnArgument(2);
$lock = $this->makeLock($db);
$lock->set('projects', 'p1', 'accessedAt', '2024-06-01 12:00:00');
$this->assertSame(['accessedAt' => '2024-06-01 12:00:00'], $captured);
$this->assertSame(0, $this->redis->exists(self::KEY_PREFIX.'projects:p1:accessedAt'));
}
public function test_set_skips_on_contention(): void
{
$key = self::KEY_PREFIX.'projects:p1:accessedAt';
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
$db = $this->createMock(Database::class);
$db->expects($this->never())->method('updateDocument');
$lock = $this->makeLock($db);
$lock->set('projects', 'p1', 'accessedAt', '2024-06-01 12:00:00');
$this->assertSame('other-owner', $this->redis->get($key));
}
public function test_set_different_attributes_do_not_compete(): void
{
// Hold accessedAt
$heldKey = self::KEY_PREFIX.'projects:p1:accessedAt';
$this->redis->set($heldKey, 'other-owner', ['NX', 'EX' => 30]);
// mcpAccessedAt should still be acquirable
$db = $this->createMock(Database::class);
$db->expects($this->once())
->method('updateDocument')
->with('projects', 'p1', $this->isInstanceOf(Document::class))
->willReturnArgument(2);
$lock = $this->makeLock($db);
$lock->set('projects', 'p1', 'mcpAccessedAt', '2024-06-01 12:00:00');
$this->assertSame('other-owner', $this->redis->get($heldKey));
}
public function test_run_uses_per_document_key_and_invokes_callback(): void
{
$called = false;
$lock = $this->makeLock();
$lock->run('keys', 'k1', function () use (&$called) {
$called = true;
});
$this->assertTrue($called);
$this->assertSame(0, $this->redis->exists(self::KEY_PREFIX.'keys:k1'));
}
public function test_run_skips_on_contention(): void
{
$key = self::KEY_PREFIX.'keys:k1';
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
$called = false;
$lock = $this->makeLock();
$lock->run('keys', 'k1', function () use (&$called) {
$called = true;
});
$this->assertFalse($called);
$this->assertSame('other-owner', $this->redis->get($key));
}
public function test_run_or_fail_throws_on_contention(): void
{
$key = self::KEY_PREFIX.'projects:p1';
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
$lock = $this->makeLock();
$this->expectException(Exception::class);
try {
$lock->runOrFail('projects', 'p1', fn () => 'never-runs');
} catch (Exception $e) {
$this->assertSame(Exception::GENERAL_RESOURCE_LOCKED, $e->getType());
throw $e;
}
}
public function test_run_or_fail_returns_callback_value_when_uncontended(): void
{
$lock = $this->makeLock();
$result = $lock->runOrFail('projects', 'p1', fn () => 'ok');
$this->assertSame('ok', $result);
$this->assertSame(0, $this->redis->exists(self::KEY_PREFIX.'projects:p1'));
}
public function test_with_key_uses_raw_key(): void
{
$custom = 'lock:test:custom-key';
$called = false;
$lock = $this->makeLock();
$lock->withKey($custom, function () use (&$called) {
$called = true;
});
$this->assertTrue($called);
$this->assertSame(0, $this->redis->exists($custom));
}
public function test_with_key_or_fail_flag_throws_on_contention(): void
{
$custom = 'lock:test:contended';
$this->redis->set($custom, 'other', ['NX', 'EX' => 30]);
$lock = $this->makeLock();
$this->expectException(Exception::class);
$lock->withKey($custom, fn () => null, ttl: 5, orFail: true, waitTimeout: 0.1);
}
public function test_disabled_mode_runs_callback_unlocked(): void
{
$previous = \getenv('_APP_LOCKING_ENABLED');
\putenv('_APP_LOCKING_ENABLED=disabled');
try {
// Even when the key is already held, the callback must still run.
$key = self::KEY_PREFIX.'keys:k1';
$this->redis->set($key, 'other-owner', ['NX', 'EX' => 30]);
$called = false;
$lock = $this->makeLock();
$lock->run('keys', 'k1', function () use (&$called) {
$called = true;
});
$this->assertTrue($called);
$this->assertSame('other-owner', $this->redis->get($key));
} finally {
$previous === false ? \putenv('_APP_LOCKING_ENABLED') : \putenv('_APP_LOCKING_ENABLED='.$previous);
}
}
public function test_project_without_sequence_falls_back_to_unknown(): void
{
$emptyProject = new Document();
$lock = new Lock(
$this->redis,
new NoTelemetry(),
$this->createStub(Database::class),
$this->authorization,
$this->log,
null,
$emptyProject,
);
// Pre-acquire the lock at the 'unknown' projectInternalId path.
$key = 'lock:platform:unknown:keys:k1';
$this->redis->set($key, 'held', ['NX', 'EX' => 30]);
$called = false;
$lock->run('keys', 'k1', function () use (&$called) {
$called = true;
});
$this->assertFalse($called, 'Lock without project sequence should hash to the unknown bucket');
$this->redis->del($key);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Tests\Unit\Utopia\Request\Filters;
use Appwrite\Utopia\Request\Filter;
/**
* Test fixture: a filter that always throws, with a configurable code.
* Used to assert how Request::getParams() reacts to filter exceptions.
*/
class ThrowingFilter extends Filter
{
public int $calls = 0;
public function __construct(private int $code, private string $reason)
{
}
public function parse(array $content, string $model): array
{
$this->calls++;
throw new \Exception($this->reason, $this->code);
}
}
+105
View File
@@ -5,10 +5,12 @@ namespace Tests\Unit\Utopia;
use Appwrite\SDK\Method;
use Appwrite\SDK\Parameter;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Request\Filter;
use PHPUnit\Framework\TestCase;
use Swoole\Http\Request as SwooleRequest;
use Tests\Unit\Utopia\Request\Filters\First;
use Tests\Unit\Utopia\Request\Filters\Second;
use Tests\Unit\Utopia\Request\Filters\ThrowingFilter;
use Utopia\Http\Route;
class RequestTest extends TestCase
@@ -192,6 +194,109 @@ class RequestTest extends TestCase
$this->assertSame('fallback', $request->getHeader('referer', 'fallback'));
}
public function testGetParamsCachesRawParamsWhenFilterThrows4xx(): void
{
/*
* Regression: when a request filter throws a 4xx exception during
* Request::getParams() (e.g. RequestV20 rejecting an unparseable
* queries[]), the framework's error path calls getParams() again to
* build error-hook arguments. Without caching, that second call
* re-runs the filter and re-throws, which the framework wraps as
* "Error handler had an error: ..." (HTTP 500), masking the intended
* 400. This test pins that behavior: the first call throws (so the
* action's argument resolution aborts), but the second call returns
* the raw, pre-filter params without re-invoking filters.
*/
$filter = new ThrowingFilter(400, 'invalid input');
$this->setupSingleMethodRoute($filter);
$this->request->setQueryString(['foo' => 'bar']);
$threw = false;
try {
$this->request->getParams();
} catch (\Throwable $e) {
$threw = true;
$this->assertSame(400, $e->getCode());
$this->assertSame('invalid input', $e->getMessage());
}
$this->assertTrue($threw, 'First getParams() call must rethrow the filter exception.');
$this->assertSame(1, $filter->calls, 'Filter ran once on the first call.');
// Second call: framework's error hook arg resolution. Must return raw
// params without re-invoking the filter.
$params = $this->request->getParams();
$this->assertSame(['foo' => 'bar'], $params);
$this->assertSame(1, $filter->calls, 'Filter must not run again after a cached 4xx failure.');
}
public function testGetParamsDoesNotCacheRawParamsForServerError(): void
{
/*
* 5xx filter throws indicate genuine server-side problems, not
* user-input mistakes. They must keep rethrowing on every call so
* the framework's normal error handling sees the failure each time
* caching raw params would silently swallow real bugs.
*/
$filter = new ThrowingFilter(500, 'boom');
$this->setupSingleMethodRoute($filter);
$this->request->setQueryString(['foo' => 'bar']);
for ($attempt = 1; $attempt <= 2; $attempt++) {
$threw = false;
try {
$this->request->getParams();
} catch (\Throwable $e) {
$threw = true;
$this->assertSame(500, $e->getCode());
}
$this->assertTrue($threw, "Call #$attempt must rethrow.");
$this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt.");
}
}
public function testGetParamsDoesNotCacheRawParamsForUncodedException(): void
{
// \Exception with the default code of 0 is treated as "unknown" and
// must propagate every call — same reasoning as 5xx.
$filter = new ThrowingFilter(0, 'unknown');
$this->setupSingleMethodRoute($filter);
$this->request->setQueryString(['foo' => 'bar']);
for ($attempt = 1; $attempt <= 2; $attempt++) {
$threw = false;
try {
$this->request->getParams();
} catch (\Throwable) {
$threw = true;
}
$this->assertTrue($threw, "Call #$attempt must rethrow.");
$this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt.");
}
}
/**
* Helper to attach a route with a single SDK method and one filter.
*/
private function setupSingleMethodRoute(Filter $filter): void
{
$route = new Route(Request::METHOD_GET, '/single');
$route->label('sdk', new Method(
namespace: 'namespace',
group: 'group',
name: 'method',
description: 'description',
auth: [],
responses: [],
));
$this->request->addHeader('EXAMPLE', 'VALUE');
$this->request->setRoute($route);
$this->request->addFilter($filter);
}
/**
* Helper to attach a route with multiple SDK methods to the request.
*/