diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php new file mode 100644 index 0000000000..0c1dbad923 --- /dev/null +++ b/app/init/realtime/connection.php @@ -0,0 +1,366 @@ +getHeader('x-appwrite-project', ''); + + if (!empty($projectId)) { + return $projectId; + } + + $projectId = $request->getParam('project', ''); + + return \is_string($projectId) ? $projectId : ''; + }; + + $getMode = static function (Request $request, Document $project) use ($getProjectId): string { + $mode = $request->getParam('mode', $request->getHeader('x-appwrite-mode', APP_MODE_DEFAULT)); + $projectId = $getProjectId($request); + + if (!empty($projectId) && $project->getId() !== $projectId) { + $mode = APP_MODE_ADMIN; + } + + return $mode; + }; + + $getDbForPlatform = static function (Authorization $authorization) { + $database = getConsoleDB(); + $database->setAuthorization($authorization); + + return $database; + }; + + $getDbForProject = static function (Document $project, Authorization $authorization) use ($getDbForPlatform) { + if ($project->isEmpty() || $project->getId() === 'console') { + return $getDbForPlatform($authorization); + } + + $database = getProjectDB($project); + $database->setAuthorization($authorization); + + return $database; + }; + + $findRule = static function (Request $request, Document $project, Authorization $authorization) use ($getDbForPlatform): Document { + $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); + + if (empty($domain)) { + $domain = \parse_url($request->getReferer(), PHP_URL_HOST); + } + + if (empty($domain)) { + return new Document(); + } + + $dbForPlatform = $getDbForPlatform($authorization); + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + + $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); + } + + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); + + $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + + if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { + $trustedProjects = []; + foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { + if (empty($trustedProject)) { + continue; + } + + $trustedProjects[] = $trustedProject; + } + + if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects, true)) { + $permitsCurrentProject = true; + } + } + + if (!$permitsCurrentProject) { + return new Document(); + } + + return $rule; + }; + + $findDevKey = static function (Request $request, Document $project, array $servers, Authorization $authorization) use ($getDbForPlatform): Document { + $devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', '')); + $key = $project->find('secret', $devKey, 'devKeys'); + + if (!$key) { + return new Document([]); + } + + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + return new Document([]); + } + + $dbForPlatform = $getDbForPlatform($authorization); + $accessedAt = $key->getAttribute('accessedAt', 0); + + if (empty($accessedAt) || DatabaseDateTime::formatTz(DatabaseDateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) { + $key->setAttribute('accessedAt', DatabaseDateTime::now()); + $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'accessedAt' => $key->getAttribute('accessedAt'), + ]))); + $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + } + + $sdkValidator = new WhiteList($servers, true); + $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); + + if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + $sdks = $key->getAttribute('sdks', []); + + if (!\in_array($sdk, $sdks, true)) { + $sdks[] = $sdk; + $key->setAttribute('sdks', $sdks); + $key->setAttribute('accessedAt', DatabaseDateTime::now()); + + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('devKeys', $key->getId(), new Document([ + 'sdks' => $key->getAttribute('sdks'), + 'accessedAt' => $key->getAttribute('accessedAt'), + ]))); + $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + } + } + + return $key; + }; + + $container->set('authorization', function () { + return new Authorization(); + }, []); + + $container->set('project', function (Request $request, Document $console, Authorization $authorization) use ($getProjectId, $getDbForPlatform) { + $projectId = $getProjectId($request); + + if (empty($projectId) || $projectId === 'console') { + return $console; + } + + $dbForPlatform = $getDbForPlatform($authorization); + + return $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId)); + }, ['request', 'console', 'authorization']); + + $container->set('originValidator', function (array $platform, Request $request, Document $project, array $servers, Authorization $authorization) use ($findDevKey, $findRule) { + $devKey = $findDevKey($request, $project, $servers, $authorization); + + if (!$devKey->isEmpty()) { + return new URL(); + } + + $allowedHostnames = [...($platform['hostnames'] ?? [])]; + if (!$project->isEmpty() && $project->getId() !== 'console') { + $allowedHostnames = [...$allowedHostnames, ...Platform::getHostnames($project->getAttribute('platforms', []))]; + } + + $rule = $findRule($request, $project, $authorization); + if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) { + $allowedHostnames[] = $rule->getAttribute('domain', ''); + } + + $originHostname = \parse_url($request->getOrigin(), PHP_URL_HOST); + $refererHostname = \parse_url($request->getReferer(), PHP_URL_HOST); + $hostname = $originHostname ?: $refererHostname; + + if ($request->getMethod() === 'OPTIONS' && !empty($hostname)) { + $allowedHostnames[] = $hostname; + } + + $allowedSchemes = [...($platform['schemas'] ?? [])]; + if (!$project->isEmpty() && $project->getId() !== 'console') { + $allowedSchemes[] = 'exp'; + $allowedSchemes[] = 'appwrite-callback-' . $project->getId(); + $allowedSchemes = [...$allowedSchemes, ...Platform::getSchemes($project->getAttribute('platforms', []))]; + } + + return new Origin(\array_unique($allowedHostnames), \array_unique($allowedSchemes)); + }, ['platform', 'request', 'project', 'servers', 'authorization']); + + $container->set('user', function (Request $request, Document $project, Document $console, Authorization $authorization) use ($getMode, $getDbForPlatform, $getDbForProject) { + $mode = $getMode($request, $project); + $store = new Store(); + $proofForToken = new Token(); + $proofForToken->setHash(new Sha()); + + $authorization->setDefaultStatus(true); + + $dbForPlatform = $getDbForPlatform($authorization); + $dbForProject = $getDbForProject($project, $authorization); + + $store->setKey('a_session_' . $project->getId()); + if ($mode === APP_MODE_ADMIN) { + $store->setKey('a_session_' . $console->getId()); + } + + $store->decode( + $request->getCookie( + $store->getKey(), + $request->getCookie($store->getKey() . '_legacy', '') + ) + ); + + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + $sessionHeader = $request->getHeader('x-appwrite-session', ''); + + if (!empty($sessionHeader)) { + $store->decode($sessionHeader); + } + } + + if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { + $fallback = \json_decode($request->getHeader('x-fallback-cookies', ''), true); + $store->decode((\is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''); + } + + $user = null; + if ($mode === APP_MODE_ADMIN) { + /** @var User $user */ + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + if ($project->isEmpty()) { + $user = new User([]); + } elseif (!empty($store->getProperty('id', ''))) { + if ($project->getId() === 'console') { + /** @var User $user */ + $user = $dbForPlatform->getDocument('users', $store->getProperty('id', '')); + } else { + /** @var User $user */ + $user = $dbForProject->getDocument('users', $store->getProperty('id', '')); + } + } + } + + if ( + !$user + || $user->isEmpty() + || !$user->sessionVerify($store->getProperty('secret', ''), $proofForToken) + ) { + $user = new User([]); + } + + $authJWT = $request->getHeader('x-appwrite-jwt', ''); + if (!empty($authJWT) && !$project->isEmpty()) { + if (!$user->isEmpty()) { + throw new Exception(Exception::USER_JWT_AND_COOKIE_SET); + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); + + try { + $payload = $jwt->decode($authJWT); + } catch (JWTException $error) { + throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); + } + + $jwtUserId = $payload['userId'] ?? ''; + if (!empty($jwtUserId)) { + if ($mode === APP_MODE_ADMIN) { + $user = $dbForPlatform->getDocument('users', $jwtUserId); + } else { + $user = $dbForProject->getDocument('users', $jwtUserId); + } + } + + $jwtSessionId = $payload['sessionId'] ?? ''; + if (!empty($jwtSessionId) && empty($user->find('$id', $jwtSessionId, 'sessions'))) { + $user = new User([]); + } + } + + $accountKey = $request->getHeader('x-appwrite-key', ''); + $accountKeyUserId = $request->getHeader('x-appwrite-user', ''); + + if (!empty($accountKeyUserId) && !empty($accountKey)) { + if (!$user->isEmpty()) { + throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); + } + + $accountKeyUser = $authorization->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); + if (!$accountKeyUser->isEmpty()) { + $key = $accountKeyUser->find( + key: 'secret', + find: $accountKey, + subject: 'keys' + ); + + if (!empty($key)) { + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + throw new Exception(Exception::ACCOUNT_KEY_EXPIRED); + } + + $user = $accountKeyUser; + } + } + } + + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + + if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { + $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; + $targetUser = null; + + if (!empty($impersonateUserId)) { + $targetUser = $authorization->skip(fn () => $userDb->getDocument('users', $impersonateUserId)); + } elseif (!empty($impersonateEmail)) { + $targetUser = $authorization->skip(fn () => $userDb->findOne('users', [ + Query::equal('email', [\strtolower($impersonateEmail)]), + ])); + } elseif (!empty($impersonatePhone)) { + $targetUser = $authorization->skip(fn () => $userDb->findOne('users', [ + Query::equal('phone', [$impersonatePhone]), + ])); + } + + if ($targetUser !== null && !$targetUser->isEmpty()) { + $impersonator = clone $user; + $user = clone $targetUser; + $user->setAttribute('impersonatorUserId', $impersonator->getId()); + $user->setAttribute('impersonatorUserInternalId', $impersonator->getSequence()); + $user->setAttribute('impersonatorUserName', $impersonator->getAttribute('name', '')); + $user->setAttribute('impersonatorUserEmail', $impersonator->getAttribute('email', '')); + $user->setAttribute('impersonatorAccessedAt', $impersonator->getAttribute('accessedAt', 0)); + } + } + + $dbForPlatform->setMetadata('user', $user->getId()); + $dbForProject->setMetadata('user', $user->getId()); + + return $user; + }, ['request', 'project', 'console', 'authorization']); +}; diff --git a/app/realtime.php b/app/realtime.php index 81c81f8b98..e1a930d4ab 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -35,8 +35,6 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\DI\Container; use Utopia\DSN\DSN; -use Utopia\Http\Adapter\FPM\Server as HttpServer; -use Utopia\Http\Http; use Utopia\Logger\Log; use Utopia\Pools\Group; use Utopia\Registry\Registry; @@ -45,12 +43,12 @@ use Utopia\Telemetry\Adapter\None as NoTelemetry; use Utopia\WebSocket\Adapter; use Utopia\WebSocket\Server; -/** - * @var Registry $register - */ require_once __DIR__ . '/init.php'; -$registerRequestResources ??= require __DIR__ . '/init/resources/request.php'; +/** @var Registry $register */ +$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized'); + +$registerConnectionResources ??= require __DIR__ . '/init/realtime/connection.php'; Runtime::enableCoroutine(SWOOLE_HOOK_ALL); @@ -557,7 +555,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $receivers = $realtime->getSubscribers($event); - if (Http::isDevelopment() && !empty($receivers)) { + if (System::getEnv('_APP_ENV', 'production') === 'development' && !empty($receivers)) { Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); Console::log("[Debug][Worker {$workerId}] Connection IDs: " . json_encode(array_keys($receivers))); Console::log("[Debug][Worker {$workerId}] Matched: " . json_encode(array_values($receivers))); @@ -623,7 +621,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); -$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerRequestResources) { +$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) { global $container; $request = new Request($request); $response = new Response(new SwooleResponse()); @@ -631,14 +629,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Console::info("Connection open (user: {$connection})"); $connectionContainer = new Container($container); - - $adapter = new HttpServer($connectionContainer); - $app = new Http($adapter, 'UTC'); - $connectionContainer->set('utopia', fn () => $app); $connectionContainer->set('request', fn () => $request); - $connectionContainer->set('response', fn () => $response); - $registerRequestResources($connectionContainer); + $registerConnectionResources($connectionContainer); $project = null; $logUser = null; @@ -646,8 +639,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, try { /** @var Document $project */ - $project = $app->getResource('project'); - $authorization = $app->getResource('authorization'); + $project = $connectionContainer->get('project'); + $authorization = $connectionContainer->get('authorization'); /* * Project Check @@ -656,8 +649,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing or unknown project ID'); } - $timelimit = $app->getResource('timelimit'); - $user = $app->getResource('user'); /** @var User $user */ + $timelimit = $connectionContainer->get('timelimit'); + $user = $connectionContainer->get('user'); /** @var User $user */ $logUser = $user; if ( @@ -702,7 +695,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, * Skip this check for non-web platforms which are not required to send an origin header. */ $origin = $request->getOrigin(); - $originValidator = $app->getResource('originValidator'); + $originValidator = $connectionContainer->get('originValidator'); if (!empty($origin) && !$originValidator->isValid($origin) && $project->getId() !== 'console') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $originValidator->getDescription()); @@ -809,7 +802,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, // sanitize 0 && 5xx errors $realtimeViolation = $th instanceof AppwriteException && $th->getType() === AppwriteException::REALTIME_POLICY_VIOLATION; - if (($code === 0 || $code >= 500) && !$realtimeViolation && !Http::isDevelopment()) { + if (($code === 0 || $code >= 500) && !$realtimeViolation && System::getEnv('_APP_ENV', 'production') !== 'development') { $message = 'Error: Server Error'; } @@ -824,7 +817,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->send([$connection], json_encode($response)); $server->close($connection, $code); - if (Http::isDevelopment()) { + if (System::getEnv('_APP_ENV', 'production') === 'development') { Console::error('[Error] Connection Error'); Console::error('[Error] Code: ' . $response['data']['code']); Console::error('[Error] Message: ' . $response['data']['message']); @@ -1101,7 +1094,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $message = $th->getMessage(); // sanitize 0 && 5xx errors - if (($code === 0 || $code >= 500) && !Http::isDevelopment()) { + if (($code === 0 || $code >= 500) && System::getEnv('_APP_ENV', 'production') !== 'development') { $message = 'Error: Server Error'; } diff --git a/app/worker.php b/app/worker.php index e55abb587c..7cc34f397c 100644 --- a/app/worker.php +++ b/app/worker.php @@ -1,8 +1,8 @@