mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
737c85822d | ||
|
|
1fc3a8803c | ||
|
|
c32294743a | ||
|
|
f5a7cfd2ea | ||
|
|
76e6239d32 | ||
|
|
1da5b549af | ||
|
|
92eceba218 | ||
|
|
8f68a59a79 | ||
|
|
6051b8150c | ||
|
|
4e20e382d2 | ||
|
|
c96922422f | ||
|
|
7f27851dab | ||
|
|
f396064c49 | ||
|
|
413930a15e | ||
|
|
f672891c42 | ||
|
|
65f926b4c5 | ||
|
|
08ad7d7f71 | ||
|
|
71300383b2 | ||
|
|
8785aa9877 | ||
|
|
62b7d5558f | ||
|
|
fbbab0f7e1 | ||
|
|
86b9599a57 | ||
|
|
526b390c15 | ||
|
|
b73ba68bfb | ||
|
|
d98bd8c972 | ||
|
|
81c580bf50 | ||
|
|
c0bba74eee | ||
|
|
337d47b1d9 | ||
|
|
d099167d18 | ||
|
|
9d3255f5cd | ||
|
|
f03cc847f8 | ||
|
|
18b9769672 | ||
|
|
8eed06678b | ||
|
|
7a9a2899ff |
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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']
|
||||
],
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
])));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
// =========================================================================
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user