diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 86996f4a58..214af8e440 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -31,12 +31,14 @@ use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -App::init(function (Document $project) { - - if ($project->getId() !== 'console') { - throw new Exception('Access to this API is forbidden.', 401, Exception::GENERAL_ACCESS_FORBIDDEN); - } -}, ['project'], 'projects'); +App::init() + ->groups(['projects']) + ->inject('project') + ->action(function (Document $project) { + if ($project->getId() !== 'console') { + throw new Exception('Access to this API is forbidden.', 401, Exception::GENERAL_ACCESS_FORBIDDEN); + } + }); App::post('/v1/projects') ->desc('Create Project') diff --git a/app/controllers/general.php b/app/controllers/general.php index 620f16f1c1..c87d53c290 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -35,485 +35,506 @@ Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); -App::init(function (App $utopia, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $clients) { +App::init() + ->inject('utopia') + ->inject('request') + ->inject('response') + ->inject('console') + ->inject('project') + ->inject('dbForConsole') + ->inject('user') + ->inject('locale') + ->inject('clients') + ->action(function (App $utopia, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $clients) { + /* + * Request format + */ + $route = $utopia->match($request); + Request::setRoute($route); - /* - * Request format - */ - $route = $utopia->match($request); - Request::setRoute($route); - - $requestFormat = $request->getHeader('x-appwrite-response-format', App::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); - if ($requestFormat) { - switch ($requestFormat) { - case version_compare($requestFormat, '0.12.0', '<'): - Request::setFilter(new RequestV12()); - break; - case version_compare($requestFormat, '0.13.0', '<'): - Request::setFilter(new RequestV13()); - break; - case version_compare($requestFormat, '0.14.0', '<'): - Request::setFilter(new RequestV14()); - break; - default: - Request::setFilter(null); - } - } else { - Request::setFilter(null); - } - - $domain = $request->getHostname(); - $domains = Config::getParam('domains', []); - if (!array_key_exists($domain, $domains)) { - $domain = new Domain(!empty($domain) ? $domain : ''); - - if (empty($domain->get()) || !$domain->isKnown() || $domain->isTest()) { - $domains[$domain->get()] = false; - Console::warning($domain->get() . ' is not a publicly accessible domain. Skipping SSL certificate generation.'); - } elseif (str_starts_with($request->getURI(), '/.well-known/acme-challenge')) { - Console::warning('Skipping SSL certificates generation on ACME challenge.'); - } else { - Authorization::disable(); - - $envDomain = App::getEnv('_APP_DOMAIN', ''); - $mainDomain = null; - if (!empty($envDomain) && $envDomain !== 'localhost') { - $mainDomain = $envDomain; - } else { - $domainDocument = $dbForConsole->findOne('domains', [], 0, ['_id'], ['ASC']); - $mainDomain = $domainDocument ? $domainDocument->getAttribute('domain') : $domain->get(); + $requestFormat = $request->getHeader('x-appwrite-response-format', App::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); + if ($requestFormat) { + switch ($requestFormat) { + case version_compare($requestFormat, '0.12.0', '<'): + Request::setFilter(new RequestV12()); + break; + case version_compare($requestFormat, '0.13.0', '<'): + Request::setFilter(new RequestV13()); + break; + case version_compare($requestFormat, '0.14.0', '<'): + Request::setFilter(new RequestV14()); + break; + default: + Request::setFilter(null); } + } else { + Request::setFilter(null); + } - if ($mainDomain !== $domain->get()) { - Console::warning($domain->get() . ' is not a main domain. Skipping SSL certificate generation.'); + $domain = $request->getHostname(); + $domains = Config::getParam('domains', []); + if (!array_key_exists($domain, $domains)) { + $domain = new Domain(!empty($domain) ? $domain : ''); + + if (empty($domain->get()) || !$domain->isKnown() || $domain->isTest()) { + $domains[$domain->get()] = false; + Console::warning($domain->get() . ' is not a publicly accessible domain. Skipping SSL certificate generation.'); + } elseif (str_starts_with($request->getURI(), '/.well-known/acme-challenge')) { + Console::warning('Skipping SSL certificates generation on ACME challenge.'); } else { - $domainDocument = $dbForConsole->findOne('domains', [ - new Query('domain', QUERY::TYPE_EQUAL, [$domain->get()]) - ]); + Authorization::disable(); - if (!$domainDocument) { - $domainDocument = new Document([ - 'domain' => $domain->get(), - 'tld' => $domain->getSuffix(), - 'registerable' => $domain->getRegisterable(), - 'verification' => false, - 'certificateId' => null, + $envDomain = App::getEnv('_APP_DOMAIN', ''); + $mainDomain = null; + if (!empty($envDomain) && $envDomain !== 'localhost') { + $mainDomain = $envDomain; + } else { + $domainDocument = $dbForConsole->findOne('domains', [], 0, ['_id'], ['ASC']); + $mainDomain = $domainDocument ? $domainDocument->getAttribute('domain') : $domain->get(); + } + + if ($mainDomain !== $domain->get()) { + Console::warning($domain->get() . ' is not a main domain. Skipping SSL certificate generation.'); + } else { + $domainDocument = $dbForConsole->findOne('domains', [ + new Query('domain', QUERY::TYPE_EQUAL, [$domain->get()]) ]); - $domainDocument = $dbForConsole->createDocument('domains', $domainDocument); + if (!$domainDocument) { + $domainDocument = new Document([ + 'domain' => $domain->get(), + 'tld' => $domain->getSuffix(), + 'registerable' => $domain->getRegisterable(), + 'verification' => false, + 'certificateId' => null, + ]); - Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); + $domainDocument = $dbForConsole->createDocument('domains', $domainDocument); - (new Certificate()) - ->setDomain($domainDocument) - ->trigger(); + Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); + + (new Certificate()) + ->setDomain($domainDocument) + ->trigger(); + } } + $domains[$domain->get()] = true; + + Authorization::reset(); // ensure authorization is re-enabled } - $domains[$domain->get()] = true; - - Authorization::reset(); // ensure authorization is re-enabled - } - Config::setParam('domains', $domains); - } - - $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); - if (\in_array($localeParam, Config::getParam('locale-codes'))) { - $locale->setDefault($localeParam); - } - - if ($project->isEmpty()) { - throw new AppwriteException('Project not found', 404, AppwriteException::PROJECT_NOT_FOUND); - } - - if (!empty($route->getLabel('sdk.auth', [])) && $project->isEmpty() && ($route->getLabel('scope', '') !== 'public')) { - throw new AppwriteException('Missing or unknown project ID', 400, AppwriteException::PROJECT_UNKNOWN); - } - - $referrer = $request->getReferer(); - $origin = \parse_url($request->getOrigin($referrer), PHP_URL_HOST); - $protocol = \parse_url($request->getOrigin($referrer), PHP_URL_SCHEME); - $port = \parse_url($request->getOrigin($referrer), PHP_URL_PORT); - - $refDomainOrigin = 'localhost'; - $validator = new Hostname($clients); - if ($validator->isValid($origin)) { - $refDomainOrigin = $origin; - } - - $refDomain = (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $refDomainOrigin . (!empty($port) ? ':' . $port : ''); - - $refDomain = (!$route->getLabel('origin', false)) // This route is publicly accessible - ? $refDomain - : (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $origin . (!empty($port) ? ':' . $port : ''); - - $selfDomain = new Domain($request->getHostname()); - $endDomain = new Domain((string)$origin); - - Config::setParam( - 'domainVerification', - ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) && - $endDomain->getRegisterable() !== '' - ); - - Config::setParam('cookieDomain', ( - $request->getHostname() === 'localhost' || - $request->getHostname() === 'localhost:' . $request->getPort() || - (\filter_var($request->getHostname(), FILTER_VALIDATE_IP) !== false) - ) - ? null - : '.' . $request->getHostname()); - - /* - * Response format - */ - $responseFormat = $request->getHeader('x-appwrite-response-format', App::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); - if ($responseFormat) { - switch ($responseFormat) { - case version_compare($responseFormat, '0.11.2', '<='): - Response::setFilter(new ResponseV11()); - break; - case version_compare($responseFormat, '0.12.4', '<='): - Response::setFilter(new ResponseV12()); - break; - case version_compare($responseFormat, '0.13.4', '<='): - Response::setFilter(new ResponseV13()); - break; - case version_compare($responseFormat, '0.14.0', '<='): - Response::setFilter(new ResponseV14()); - break; - default: - Response::setFilter(null); - } - } else { - Response::setFilter(null); - } - - /* - * Security Headers - * - * As recommended at: - * @see https://www.owasp.org/index.php/List_of_useful_HTTP_headers - */ - if (App::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'disabled') === 'enabled') { // Force HTTPS - if ($request->getProtocol() !== 'https') { - if ($request->getMethod() !== Request::METHOD_GET) { - throw new AppwriteException('Method unsupported over HTTP.', 500, AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED); - } - - return $response->redirect('https://' . $request->getHostname() . $request->getURI()); + Config::setParam('domains', $domains); } - $response->addHeader('Strict-Transport-Security', 'max-age=' . (60 * 60 * 24 * 126)); // 126 days - } - - $response - ->addHeader('Server', 'Appwrite') - ->addHeader('X-Content-Type-Options', 'nosniff') - ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-Appwrite-ID, Content-Range, Range, Cache-Control, Expires, Pragma') - ->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') - ->addHeader('Access-Control-Allow-Origin', $refDomain) - ->addHeader('Access-Control-Allow-Credentials', 'true') - ; - - /* - * Validate Client Domain - Check to avoid CSRF attack - * Adding Appwrite API domains to allow XDOMAIN communication - * Skip this check for non-web platforms which are not required to send an origin header - */ - $origin = $request->getOrigin($request->getReferer('')); - $originValidator = new Origin(\array_merge($project->getAttribute('platforms', []), $console->getAttribute('platforms', []))); - - if ( - !$originValidator->isValid($origin) - && \in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH, Request::METHOD_DELETE]) - && $route->getLabel('origin', false) !== '*' - && empty($request->getHeader('x-appwrite-key', '')) - ) { - throw new AppwriteException($originValidator->getDescription(), 403, AppwriteException::GENERAL_UNKNOWN_ORIGIN); - } - - /* - * ACL Check - */ - $role = ($user->isEmpty()) ? Auth::USER_ROLE_GUEST : Auth::USER_ROLE_MEMBER; - - // Add user roles - $memberships = $user->find('teamId', $project->getAttribute('teamId', null), 'memberships'); - - if ($memberships) { - foreach ($memberships->getAttribute('roles', []) as $memberRole) { - switch ($memberRole) { - case 'owner': - $role = Auth::USER_ROLE_OWNER; - break; - case 'admin': - $role = Auth::USER_ROLE_ADMIN; - break; - case 'developer': - $role = Auth::USER_ROLE_DEVELOPER; - break; - } + $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); + if (\in_array($localeParam, Config::getParam('locale-codes'))) { + $locale->setDefault($localeParam); } - } - $roles = Config::getParam('roles', []); - $scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route - $scopes = $roles[$role]['scopes']; // Allowed scopes for user role - - $authKey = $request->getHeader('x-appwrite-key', ''); - - if (!empty($authKey)) { // API Key authentication - // Check if given key match project API keys - $key = $project->find('secret', $authKey, 'keys'); - - /* - * Try app auth when we have project key and no user - * Mock user to app and grant API key scopes in addition to default app scopes - */ - if ($key && $user->isEmpty()) { - $user = new Document([ - '$id' => '', - 'status' => true, - 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), - 'password' => '', - 'name' => $project->getAttribute('name', 'Untitled'), - ]); - - $role = Auth::USER_ROLE_APP; - $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); - - $expire = $key->getAttribute('expire', 0); - - if (!empty($expire) && $expire < \time()) { - throw new AppwriteException('Project key expired', 401, AppwriteException:: PROJECT_KEY_EXPIRED); - } - - Authorization::setRole('role:' . Auth::USER_ROLE_APP); - Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. - } - } - - Authorization::setRole('role:' . $role); - - foreach (Auth::getRoles($user) as $authRole) { - Authorization::setRole($authRole); - } - - $service = $route->getLabel('sdk.namespace', ''); - if (!empty($service)) { - if ( - array_key_exists($service, $project->getAttribute('services', [])) - && !$project->getAttribute('services', [])[$service] - && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) - ) { - throw new AppwriteException('Service is disabled', 503, AppwriteException::GENERAL_SERVICE_DISABLED); - } - } - - if (!\in_array($scope, $scopes)) { - if ($project->isEmpty()) { // Check if permission is denied because project is missing + if ($project->isEmpty()) { throw new AppwriteException('Project not found', 404, AppwriteException::PROJECT_NOT_FOUND); } - throw new AppwriteException($user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scope (' . $scope . ')', 401, AppwriteException::GENERAL_UNAUTHORIZED_SCOPE); - } - - if (false === $user->getAttribute('status')) { // Account is blocked - throw new AppwriteException('Invalid credentials. User is blocked', 401, AppwriteException::USER_BLOCKED); - } - - if ($user->getAttribute('reset')) { - throw new AppwriteException('Password reset is required', 412, AppwriteException::USER_PASSWORD_RESET_REQUIRED); - } -}, ['utopia', 'request', 'response', 'console', 'project', 'dbForConsole', 'user', 'locale', 'clients']); - -App::options(function (Request $request, Response $response) { - - $origin = $request->getOrigin(); - - $response - ->addHeader('Server', 'Appwrite') - ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-Appwrite-ID, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies') - ->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') - ->addHeader('Access-Control-Allow-Origin', $origin) - ->addHeader('Access-Control-Allow-Credentials', 'true') - ->noContent(); -}, ['request', 'response']); - -App::error(function (Throwable $error, App $utopia, Request $request, Response $response, View $layout, Document $project, ?Logger $logger, array $loggerBreadcrumbs) { - - $version = App::getEnv('_APP_VERSION', 'UNKNOWN'); - $route = $utopia->match($request); - - /** Delegate PDO exceptions to the global handler so the database connection can be returned to the pool */ - if ($error instanceof PDOException) { - throw $error; - } - - if ($logger) { - if ($error->getCode() >= 500 || $error->getCode() === 0) { - try { - /** @var Utopia\Database\Document $user */ - $user = $utopia->getResource('user'); - } catch (\Throwable $th) { - // All good, user is optional information for logger - } - - $log = new Utopia\Logger\Log(); - - if (isset($user) && !$user->isEmpty()) { - $log->setUser(new User($user->getId())); - } - - $log->setNamespace("http"); - $log->setServer(\gethostname()); - $log->setVersion($version); - $log->setType(Log::TYPE_ERROR); - $log->setMessage($error->getMessage()); - - $log->addTag('method', $route->getMethod()); - $log->addTag('url', $route->getPath()); - $log->addTag('verboseType', get_class($error)); - $log->addTag('code', $error->getCode()); - $log->addTag('projectId', $project->getId()); - $log->addTag('hostname', $request->getHostname()); - $log->addTag('locale', (string)$request->getParam('locale', $request->getHeader('x-appwrite-locale', ''))); - - $log->addExtra('file', $error->getFile()); - $log->addExtra('line', $error->getLine()); - $log->addExtra('trace', $error->getTraceAsString()); - $log->addExtra('detailedTrace', $error->getTrace()); - $log->addExtra('roles', Authorization::$roles); - - $action = $route->getLabel("sdk.namespace", "UNKNOWN_NAMESPACE") . '.' . $route->getLabel("sdk.method", "UNKNOWN_METHOD"); - $log->setAction($action); - - $isProduction = App::getEnv('_APP_ENV', 'development') === 'production'; - $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); - - foreach ($loggerBreadcrumbs as $loggerBreadcrumb) { - $log->addBreadcrumb($loggerBreadcrumb); - } - - $responseCode = $logger->addLog($log); - Console::info('Log pushed with status code: ' . $responseCode); - } - } - - $code = $error->getCode(); - $message = $error->getMessage(); - $file = $error->getFile(); - $line = $error->getLine(); - $trace = $error->getTrace(); - - if (php_sapi_name() === 'cli') { - Console::error('[Error] Timestamp: ' . date('c', time())); - - if ($route) { - Console::error('[Error] Method: ' . $route->getMethod()); - Console::error('[Error] URL: ' . $route->getPath()); + if (!empty($route->getLabel('sdk.auth', [])) && $project->isEmpty() && ($route->getLabel('scope', '') !== 'public')) { + throw new AppwriteException('Missing or unknown project ID', 400, AppwriteException::PROJECT_UNKNOWN); } - Console::error('[Error] Type: ' . get_class($error)); - Console::error('[Error] Message: ' . $message); - Console::error('[Error] File: ' . $file); - Console::error('[Error] Line: ' . $line); - } + $referrer = $request->getReferer(); + $origin = \parse_url($request->getOrigin($referrer), PHP_URL_HOST); + $protocol = \parse_url($request->getOrigin($referrer), PHP_URL_SCHEME); + $port = \parse_url($request->getOrigin($referrer), PHP_URL_PORT); - /** Handle Utopia Errors */ - if ($error instanceof Utopia\Exception) { - $error = new AppwriteException($message, $code, AppwriteException::GENERAL_UNKNOWN, $error); - switch ($code) { - case 400: - $error->setType(AppwriteException::GENERAL_ARGUMENT_INVALID); - break; - case 404: - $error->setType(AppwriteException::GENERAL_ROUTE_NOT_FOUND); - break; + $refDomainOrigin = 'localhost'; + $validator = new Hostname($clients); + if ($validator->isValid($origin)) { + $refDomainOrigin = $origin; } - } - /** Wrap all exceptions inside Appwrite\Extend\Exception */ - if (!($error instanceof AppwriteException)) { - $error = new AppwriteException($message, $code, AppwriteException::GENERAL_UNKNOWN, $error); - } + $refDomain = (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $refDomainOrigin . (!empty($port) ? ':' . $port : ''); - switch ($code) { // Don't show 500 errors! - case 400: // Error allowed publicly - case 401: // Error allowed publicly - case 402: // Error allowed publicly - case 403: // Error allowed publicly - case 404: // Error allowed publicly - case 409: // Error allowed publicly - case 412: // Error allowed publicly - case 416: // Error allowed publicly - case 429: // Error allowed publicly - case 501: // Error allowed publicly - case 503: // Error allowed publicly - break; - default: - $code = 500; // All other errors get the generic 500 server error status code - $message = 'Server Error'; - } + $refDomain = (!$route->getLabel('origin', false)) // This route is publicly accessible + ? $refDomain + : (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $origin . (!empty($port) ? ':' . $port : ''); - //$_SERVER = []; // Reset before reporting to error log to avoid keys being compromised + $selfDomain = new Domain($request->getHostname()); + $endDomain = new Domain((string)$origin); - $type = $error->getType(); + Config::setParam( + 'domainVerification', + ($selfDomain->getRegisterable() === $endDomain->getRegisterable()) && + $endDomain->getRegisterable() !== '' + ); - $output = ((App::isDevelopment())) ? [ - 'message' => $message, - 'code' => $code, - 'file' => $file, - 'line' => $line, - 'trace' => $trace, - 'version' => $version, - 'type' => $type, - ] : [ - 'message' => $message, - 'code' => $code, - 'version' => $version, - 'type' => $type, - ]; + Config::setParam('cookieDomain', ( + $request->getHostname() === 'localhost' || + $request->getHostname() === 'localhost:' . $request->getPort() || + (\filter_var($request->getHostname(), FILTER_VALIDATE_IP) !== false) + ) + ? null + : '.' . $request->getHostname()); - $response - ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') - ->addHeader('Expires', '0') - ->addHeader('Pragma', 'no-cache') - ->setStatusCode($code) - ; + /* + * Response format + */ + $responseFormat = $request->getHeader('x-appwrite-response-format', App::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); + if ($responseFormat) { + switch ($responseFormat) { + case version_compare($responseFormat, '0.11.2', '<='): + Response::setFilter(new ResponseV11()); + break; + case version_compare($responseFormat, '0.12.4', '<='): + Response::setFilter(new ResponseV12()); + break; + case version_compare($responseFormat, '0.13.4', '<='): + Response::setFilter(new ResponseV13()); + break; + case version_compare($responseFormat, '0.14.0', '<='): + Response::setFilter(new ResponseV14()); + break; + default: + Response::setFilter(null); + } + } else { + Response::setFilter(null); + } - $template = ($route) ? $route->getLabel('error', null) : null; + /* + * Security Headers + * + * As recommended at: + * @see https://www.owasp.org/index.php/List_of_useful_HTTP_headers + */ + if (App::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'disabled') === 'enabled') { // Force HTTPS + if ($request->getProtocol() !== 'https') { + if ($request->getMethod() !== Request::METHOD_GET) { + throw new AppwriteException('Method unsupported over HTTP.', 500, AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED); + } - if ($template) { - $comp = new View($template); + return $response->redirect('https://' . $request->getHostname() . $request->getURI()); + } - $comp - ->setParam('development', App::isDevelopment()) - ->setParam('projectName', $project->getAttribute('name')) - ->setParam('projectURL', $project->getAttribute('url')) - ->setParam('message', $error->getMessage()) - ->setParam('code', $code) - ->setParam('trace', $trace) + $response->addHeader('Strict-Transport-Security', 'max-age=' . (60 * 60 * 24 * 126)); // 126 days + } + + $response + ->addHeader('Server', 'Appwrite') + ->addHeader('X-Content-Type-Options', 'nosniff') + ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') + ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-Appwrite-ID, Content-Range, Range, Cache-Control, Expires, Pragma') + ->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') + ->addHeader('Access-Control-Allow-Origin', $refDomain) + ->addHeader('Access-Control-Allow-Credentials', 'true') ; - $layout - ->setParam('title', $project->getAttribute('name') . ' - Error') - ->setParam('description', 'No Description') - ->setParam('body', $comp) - ->setParam('version', $version) - ->setParam('litespeed', false) + /* + * Validate Client Domain - Check to avoid CSRF attack + * Adding Appwrite API domains to allow XDOMAIN communication + * Skip this check for non-web platforms which are not required to send an origin header + */ + $origin = $request->getOrigin($request->getReferer('')); + $originValidator = new Origin(\array_merge($project->getAttribute('platforms', []), $console->getAttribute('platforms', []))); + + if ( + !$originValidator->isValid($origin) + && \in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH, Request::METHOD_DELETE]) + && $route->getLabel('origin', false) !== '*' + && empty($request->getHeader('x-appwrite-key', '')) + ) { + throw new AppwriteException($originValidator->getDescription(), 403, AppwriteException::GENERAL_UNKNOWN_ORIGIN); + } + + /* + * ACL Check + */ + $role = ($user->isEmpty()) ? Auth::USER_ROLE_GUEST : Auth::USER_ROLE_MEMBER; + + // Add user roles + $memberships = $user->find('teamId', $project->getAttribute('teamId', null), 'memberships'); + + if ($memberships) { + foreach ($memberships->getAttribute('roles', []) as $memberRole) { + switch ($memberRole) { + case 'owner': + $role = Auth::USER_ROLE_OWNER; + break; + case 'admin': + $role = Auth::USER_ROLE_ADMIN; + break; + case 'developer': + $role = Auth::USER_ROLE_DEVELOPER; + break; + } + } + } + + $roles = Config::getParam('roles', []); + $scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route + $scopes = $roles[$role]['scopes']; // Allowed scopes for user role + + $authKey = $request->getHeader('x-appwrite-key', ''); + + if (!empty($authKey)) { // API Key authentication + // Check if given key match project API keys + $key = $project->find('secret', $authKey, 'keys'); + + /* + * Try app auth when we have project key and no user + * Mock user to app and grant API key scopes in addition to default app scopes + */ + if ($key && $user->isEmpty()) { + $user = new Document([ + '$id' => '', + 'status' => true, + 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), + 'password' => '', + 'name' => $project->getAttribute('name', 'Untitled'), + ]); + + $role = Auth::USER_ROLE_APP; + $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); + + $expire = $key->getAttribute('expire', 0); + + if (!empty($expire) && $expire < \time()) { + throw new AppwriteException('Project key expired', 401, AppwriteException:: PROJECT_KEY_EXPIRED); + } + + Authorization::setRole('role:' . Auth::USER_ROLE_APP); + Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. + } + } + + Authorization::setRole('role:' . $role); + + foreach (Auth::getRoles($user) as $authRole) { + Authorization::setRole($authRole); + } + + $service = $route->getLabel('sdk.namespace', ''); + if (!empty($service)) { + if ( + array_key_exists($service, $project->getAttribute('services', [])) + && !$project->getAttribute('services', [])[$service] + && !(Auth::isPrivilegedUser(Authorization::getRoles()) || Auth::isAppUser(Authorization::getRoles())) + ) { + throw new AppwriteException('Service is disabled', 503, AppwriteException::GENERAL_SERVICE_DISABLED); + } + } + + if (!\in_array($scope, $scopes)) { + if ($project->isEmpty()) { // Check if permission is denied because project is missing + throw new AppwriteException('Project not found', 404, AppwriteException::PROJECT_NOT_FOUND); + } + + throw new AppwriteException($user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scope (' . $scope . ')', 401, AppwriteException::GENERAL_UNAUTHORIZED_SCOPE); + } + + if (false === $user->getAttribute('status')) { // Account is blocked + throw new AppwriteException('Invalid credentials. User is blocked', 401, AppwriteException::USER_BLOCKED); + } + + if ($user->getAttribute('reset')) { + throw new AppwriteException('Password reset is required', 412, AppwriteException::USER_PASSWORD_RESET_REQUIRED); + } + }); + +App::options() + ->inject('request') + ->inject('response') + ->action(function (Request $request, Response $response) { + + $origin = $request->getOrigin(); + + $response + ->addHeader('Server', 'Appwrite') + ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') + ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-SDK-Version, X-Appwrite-ID, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies') + ->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') + ->addHeader('Access-Control-Allow-Origin', $origin) + ->addHeader('Access-Control-Allow-Credentials', 'true') + ->noContent(); + }); + +App::error() + ->inject('error') + ->inject('utopia') + ->inject('request') + ->inject('response') + ->inject('layout') + ->inject('project') + ->inject('logger') + ->inject('loggerBreadcrumbs') + ->action(function (Throwable $error, App $utopia, Request $request, Response $response, View $layout, Document $project, ?Logger $logger, array $loggerBreadcrumbs) { + + $version = App::getEnv('_APP_VERSION', 'UNKNOWN'); + $route = $utopia->match($request); + + /** Delegate PDO exceptions to the global handler so the database connection can be returned to the pool */ + if ($error instanceof PDOException) { + throw $error; + } + + if ($logger) { + if ($error->getCode() >= 500 || $error->getCode() === 0) { + try { + /** @var Utopia\Database\Document $user */ + $user = $utopia->getResource('user'); + } catch (\Throwable $th) { + // All good, user is optional information for logger + } + + $log = new Utopia\Logger\Log(); + + if (isset($user) && !$user->isEmpty()) { + $log->setUser(new User($user->getId())); + } + + $log->setNamespace("http"); + $log->setServer(\gethostname()); + $log->setVersion($version); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($error->getMessage()); + + $log->addTag('method', $route->getMethod()); + $log->addTag('url', $route->getPath()); + $log->addTag('verboseType', get_class($error)); + $log->addTag('code', $error->getCode()); + $log->addTag('projectId', $project->getId()); + $log->addTag('hostname', $request->getHostname()); + $log->addTag('locale', (string)$request->getParam('locale', $request->getHeader('x-appwrite-locale', ''))); + + $log->addExtra('file', $error->getFile()); + $log->addExtra('line', $error->getLine()); + $log->addExtra('trace', $error->getTraceAsString()); + $log->addExtra('detailedTrace', $error->getTrace()); + $log->addExtra('roles', Authorization::$roles); + + $action = $route->getLabel("sdk.namespace", "UNKNOWN_NAMESPACE") . '.' . $route->getLabel("sdk.method", "UNKNOWN_METHOD"); + $log->setAction($action); + + $isProduction = App::getEnv('_APP_ENV', 'development') === 'production'; + $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); + + foreach ($loggerBreadcrumbs as $loggerBreadcrumb) { + $log->addBreadcrumb($loggerBreadcrumb); + } + + $responseCode = $logger->addLog($log); + Console::info('Log pushed with status code: ' . $responseCode); + } + } + + $code = $error->getCode(); + $message = $error->getMessage(); + $file = $error->getFile(); + $line = $error->getLine(); + $trace = $error->getTrace(); + + if (php_sapi_name() === 'cli') { + Console::error('[Error] Timestamp: ' . date('c', time())); + + if ($route) { + Console::error('[Error] Method: ' . $route->getMethod()); + Console::error('[Error] URL: ' . $route->getPath()); + } + + Console::error('[Error] Type: ' . get_class($error)); + Console::error('[Error] Message: ' . $message); + Console::error('[Error] File: ' . $file); + Console::error('[Error] Line: ' . $line); + } + + /** Handle Utopia Errors */ + if ($error instanceof Utopia\Exception) { + $error = new AppwriteException($message, $code, AppwriteException::GENERAL_UNKNOWN, $error); + switch ($code) { + case 400: + $error->setType(AppwriteException::GENERAL_ARGUMENT_INVALID); + break; + case 404: + $error->setType(AppwriteException::GENERAL_ROUTE_NOT_FOUND); + break; + } + } + + /** Wrap all exceptions inside Appwrite\Extend\Exception */ + if (!($error instanceof AppwriteException)) { + $error = new AppwriteException($message, $code, AppwriteException::GENERAL_UNKNOWN, $error); + } + + switch ($code) { // Don't show 500 errors! + case 400: // Error allowed publicly + case 401: // Error allowed publicly + case 402: // Error allowed publicly + case 403: // Error allowed publicly + case 404: // Error allowed publicly + case 409: // Error allowed publicly + case 412: // Error allowed publicly + case 416: // Error allowed publicly + case 429: // Error allowed publicly + case 501: // Error allowed publicly + case 503: // Error allowed publicly + break; + default: + $code = 500; // All other errors get the generic 500 server error status code + $message = 'Server Error'; + } + + //$_SERVER = []; // Reset before reporting to error log to avoid keys being compromised + + $type = $error->getType(); + + $output = ((App::isDevelopment())) ? [ + 'message' => $message, + 'code' => $code, + 'file' => $file, + 'line' => $line, + 'trace' => $trace, + 'version' => $version, + 'type' => $type, + ] : [ + 'message' => $message, + 'code' => $code, + 'version' => $version, + 'type' => $type, + ]; + + $response + ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + ->addHeader('Expires', '0') + ->addHeader('Pragma', 'no-cache') + ->setStatusCode($code) ; - $response->html($layout->render()); - } + $template = ($route) ? $route->getLabel('error', null) : null; - $response->dynamic( - new Document($output), - $utopia->isDevelopment() ? Response::MODEL_ERROR_DEV : Response::MODEL_ERROR - ); -}, ['error', 'utopia', 'request', 'response', 'layout', 'project', 'logger', 'loggerBreadcrumbs']); + if ($template) { + $comp = new View($template); + + $comp + ->setParam('development', App::isDevelopment()) + ->setParam('projectName', $project->getAttribute('name')) + ->setParam('projectURL', $project->getAttribute('url')) + ->setParam('message', $error->getMessage()) + ->setParam('code', $code) + ->setParam('trace', $trace) + ; + + $layout + ->setParam('title', $project->getAttribute('name') . ' - Error') + ->setParam('description', 'No Description') + ->setParam('body', $comp) + ->setParam('version', $version) + ->setParam('litespeed', false) + ; + + $response->html($layout->render()); + } + + $response->dynamic( + new Document($output), + $utopia->isDevelopment() ? Response::MODEL_ERROR_DEV : Response::MODEL_ERROR + ); + }); App::get('/manifest.json') ->desc('Progressive app manifest file') diff --git a/app/controllers/mock.php b/app/controllers/mock.php index f681cb2482..6dd29d448c 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -558,24 +558,29 @@ App::get('/v1/mock/tests/general/oauth2/failure') ]); }); -App::shutdown(function (App $utopia, Response $response, Request $request) { +App::shutdown() + ->groups(['mock']) + ->inject('utopia') + ->inject('response') + ->inject('request') + ->action(function (App $utopia, Response $response, Request $request) { - $result = []; - $route = $utopia->match($request); - $path = APP_STORAGE_CACHE . '/tests.json'; - $tests = (\file_exists($path)) ? \json_decode(\file_get_contents($path), true) : []; + $result = []; + $route = $utopia->match($request); + $path = APP_STORAGE_CACHE . '/tests.json'; + $tests = (\file_exists($path)) ? \json_decode(\file_get_contents($path), true) : []; - if (!\is_array($tests)) { - throw new Exception('Failed to read results', 500, Exception::GENERAL_MOCK); - } + if (!\is_array($tests)) { + throw new Exception('Failed to read results', 500, Exception::GENERAL_MOCK); + } - $result[$route->getMethod() . ':' . $route->getPath()] = true; + $result[$route->getMethod() . ':' . $route->getPath()] = true; - $tests = \array_merge($tests, $result); + $tests = \array_merge($tests, $result); - if (!\file_put_contents($path, \json_encode($tests), LOCK_EX)) { - throw new Exception('Failed to save results', 500, Exception::GENERAL_MOCK); - } + if (!\file_put_contents($path, \json_encode($tests), LOCK_EX)) { + throw new Exception('Failed to save results', 500, Exception::GENERAL_MOCK); + } - $response->dynamic(new Document(['result' => $route->getMethod() . ':' . $route->getPath() . ':passed']), Response::MODEL_MOCK); -}, ['utopia', 'response', 'request'], 'mock'); + $response->dynamic(new Document(['result' => $route->getMethod() . ':' . $route->getPath() . ':passed']), Response::MODEL_MOCK); + }); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 3f5f2277d4..2d27209562 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -19,234 +19,267 @@ use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Registry\Registry; -App::init(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Event $events, Audit $audits, Mail $mails, Stats $usage, Delete $deletes, EventDatabase $database, Database $dbForProject, string $mode) { +App::init() + ->groups(['api']) + ->inject('utopia') + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('user') + ->inject('events') + ->inject('audits') + ->inject('mails') + ->inject('usage') + ->inject('deletes') + ->inject('database') + ->inject('dbForProject') + ->inject('mode') + ->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Event $events, Audit $audits, Mail $mails, Stats $usage, Delete $deletes, EventDatabase $database, Database $dbForProject, string $mode) { - $route = $utopia->match($request); + $route = $utopia->match($request); - if ($project->isEmpty() && $route->getLabel('abuse-limit', 0) > 0) { // Abuse limit requires an active project scope - throw new Exception('Missing or unknown project ID', 400, Exception::PROJECT_UNKNOWN); - } + if ($project->isEmpty() && $route->getLabel('abuse-limit', 0) > 0) { // Abuse limit requires an active project scope + throw new Exception('Missing or unknown project ID', 400, Exception::PROJECT_UNKNOWN); + } - /* - * Abuse Check - */ - $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); - $timeLimitArray = []; + /* + * Abuse Check + */ + $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); + $timeLimitArray = []; - $abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; + $abuseKeyLabel = (!is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; - foreach ($abuseKeyLabel as $abuseKey) { - $timeLimit = new TimeLimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600), $dbForProject); - $timeLimit - ->setParam('{userId}', $user->getId()) - ->setParam('{userAgent}', $request->getUserAgent('')) - ->setParam('{ip}', $request->getIP()) - ->setParam('{url}', $request->getHostname() . $route->getPath()); - $timeLimitArray[] = $timeLimit; - } + foreach ($abuseKeyLabel as $abuseKey) { + $timeLimit = new TimeLimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600), $dbForProject); + $timeLimit + ->setParam('{userId}', $user->getId()) + ->setParam('{userAgent}', $request->getUserAgent('')) + ->setParam('{ip}', $request->getIP()) + ->setParam('{url}', $request->getHostname() . $route->getPath()); + $timeLimitArray[] = $timeLimit; + } - $closestLimit = null; + $closestLimit = null; - $roles = Authorization::getRoles(); - $isPrivilegedUser = Auth::isPrivilegedUser($roles); - $isAppUser = Auth::isAppUser($roles); + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); - foreach ($timeLimitArray as $timeLimit) { - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys - if (!empty($value)) { - $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); + foreach ($timeLimitArray as $timeLimit) { + foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + if (!empty($value)) { + $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); + } + } + + $abuse = new Abuse($timeLimit); + + if ($timeLimit->limit() && ($timeLimit->remaining() < $closestLimit || is_null($closestLimit))) { + $closestLimit = $timeLimit->remaining(); + $response + ->addHeader('X-RateLimit-Limit', $timeLimit->limit()) + ->addHeader('X-RateLimit-Remaining', $timeLimit->remaining()) + ->addHeader('X-RateLimit-Reset', $timeLimit->time() + $route->getLabel('abuse-time', 3600)) + ; + } + + if ( + (App::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled' // Route is rate-limited + && $abuse->check()) // Abuse is not disabled + && (!$isAppUser && !$isPrivilegedUser) + ) { // User is not an admin or API key + throw new Exception('Too many requests', 429, Exception::GENERAL_RATE_LIMIT_EXCEEDED); } } - $abuse = new Abuse($timeLimit); - - if ($timeLimit->limit() && ($timeLimit->remaining() < $closestLimit || is_null($closestLimit))) { - $closestLimit = $timeLimit->remaining(); - $response - ->addHeader('X-RateLimit-Limit', $timeLimit->limit()) - ->addHeader('X-RateLimit-Remaining', $timeLimit->remaining()) - ->addHeader('X-RateLimit-Reset', $timeLimit->time() + $route->getLabel('abuse-time', 3600)) - ; - } - - if ( - (App::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled' // Route is rate-limited - && $abuse->check()) // Abuse is not disabled - && (!$isAppUser && !$isPrivilegedUser) - ) { // User is not an admin or API key - throw new Exception('Too many requests', 429, Exception::GENERAL_RATE_LIMIT_EXCEEDED); - } - } - - /* - * Background Jobs - */ - $events - ->setEvent($route->getLabel('event', '')) - ->setProject($project) - ->setUser($user) - ; - - $mails - ->setProject($project) - ->setUser($user) - ; - - $audits - ->setMode($mode) - ->setUserAgent($request->getUserAgent('')) - ->setIP($request->getIP()) - ->setEvent($route->getLabel('event', '')) - ->setProject($project) - ->setUser($user) - ; - - $usage - ->setParam('projectId', $project->getId()) - ->setParam('httpRequest', 1) - ->setParam('httpUrl', $request->getHostname() . $request->getURI()) - ->setParam('httpMethod', $request->getMethod()) - ->setParam('httpPath', $route->getPath()) - ->setParam('networkRequestSize', 0) - ->setParam('networkResponseSize', 0) - ->setParam('storage', 0) - ; - - $deletes->setProject($project); - $database->setProject($project); -}, ['utopia', 'request', 'response', 'project', 'user', 'events', 'audits', 'mails', 'usage', 'deletes', 'database', 'dbForProject', 'mode'], 'api'); - -App::init(function (App $utopia, Request $request, Document $project) { - - $route = $utopia->match($request); - - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - $isAppUser = Auth::isAppUser(Authorization::getRoles()); - - if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs - return; - } - - $auths = $project->getAttribute('auths', []); - switch ($route->getLabel('auth.type', '')) { - case 'emailPassword': - if (($auths['emailPassword'] ?? true) === false) { - throw new Exception('Email / Password authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); - } - break; - - case 'magic-url': - if ($project->getAttribute('usersAuthMagicURL', true) === false) { - throw new Exception('Magic URL authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); - } - break; - - case 'anonymous': - if (($auths['anonymous'] ?? true) === false) { - throw new Exception('Anonymous authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); - } - break; - - case 'invites': - if (($auths['invites'] ?? true) === false) { - throw new Exception('Invites authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); - } - break; - - case 'jwt': - if (($auths['JWT'] ?? true) === false) { - throw new Exception('JWT authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); - } - break; - - default: - throw new Exception('Unsupported authentication route', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); - break; - } -}, ['utopia', 'request', 'project'], 'auth'); - -App::shutdown(function (App $utopia, Request $request, Response $response, Document $project, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject) { - - if (!empty($events->getEvent())) { - if (empty($events->getPayload())) { - $events->setPayload($response->getPayload()); - } - /** - * Trigger functions. - */ + /* + * Background Jobs + */ $events - ->setClass(Event::FUNCTIONS_CLASS_NAME) - ->setQueue(Event::FUNCTIONS_QUEUE_NAME) - ->trigger(); + ->setEvent($route->getLabel('event', '')) + ->setProject($project) + ->setUser($user) + ; - /** - * Trigger webhooks. - */ - $events - ->setClass(Event::WEBHOOK_CLASS_NAME) - ->setQueue(Event::WEBHOOK_QUEUE_NAME) - ->trigger(); + $mails + ->setProject($project) + ->setUser($user) + ; - /** - * Trigger realtime. - */ - if ($project->getId() !== 'console') { - $allEvents = Event::generateEvents($events->getEvent(), $events->getParams()); - $payload = new Document($events->getPayload()); + $audits + ->setMode($mode) + ->setUserAgent($request->getUserAgent('')) + ->setIP($request->getIP()) + ->setEvent($route->getLabel('event', '')) + ->setProject($project) + ->setUser($user) + ; - $db = $events->getContext('database'); - $collection = $events->getContext('collection'); - $bucket = $events->getContext('bucket'); - - $target = Realtime::fromPayload( - // Pass first, most verbose event pattern - event: $allEvents[0], - payload: $payload, - project: $project, - database: $db, - collection: $collection, - bucket: $bucket, - ); - - Realtime::send( - projectId: $target['projectId'] ?? $project->getId(), - payload: $events->getPayload(), - events: $allEvents, - channels: $target['channels'], - roles: $target['roles'], - options: [ - 'permissionsChanged' => $target['permissionsChanged'], - 'userId' => $events->getParam('userId') - ] - ); - } - } - - if (!empty($audits->getResource())) { - foreach ($events->getParams() as $key => $value) { - $audits->setParam($key, $value); - } - $audits->trigger(); - } - - if (!empty($deletes->getType())) { - $deletes->trigger(); - } - - if (!empty($database->getType())) { - $database->trigger(); - } - - $route = $utopia->match($request); - if ( - App::getEnv('_APP_USAGE_STATS', 'enabled') == 'enabled' - && $project->getId() - && $mode !== APP_MODE_ADMIN // TODO: add check to make sure user is admin - && !empty($route->getLabel('sdk.namespace', null)) - ) { // Don't calculate console usage on admin mode $usage - ->setParam('networkRequestSize', $request->getSize() + $usage->getParam('storage')) - ->setParam('networkResponseSize', $response->getSize()) - ->submit(); - } -}, ['utopia', 'request', 'response', 'project', 'events', 'audits', 'usage', 'deletes', 'database', 'mode', 'dbForProject'], 'api'); + ->setParam('projectId', $project->getId()) + ->setParam('httpRequest', 1) + ->setParam('httpUrl', $request->getHostname() . $request->getURI()) + ->setParam('httpMethod', $request->getMethod()) + ->setParam('httpPath', $route->getPath()) + ->setParam('networkRequestSize', 0) + ->setParam('networkResponseSize', 0) + ->setParam('storage', 0) + ; + + $deletes->setProject($project); + $database->setProject($project); + }); + +App::init() + ->groups(['auth']) + ->inject('utopia') + ->inject('request') + ->inject('project') + ->action(function (App $utopia, Request $request, Document $project) { + + $route = $utopia->match($request); + + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $isAppUser = Auth::isAppUser(Authorization::getRoles()); + + if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs + return; + } + + $auths = $project->getAttribute('auths', []); + switch ($route->getLabel('auth.type', '')) { + case 'emailPassword': + if (($auths['emailPassword'] ?? true) === false) { + throw new Exception('Email / Password authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); + } + break; + + case 'magic-url': + if ($project->getAttribute('usersAuthMagicURL', true) === false) { + throw new Exception('Magic URL authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); + } + break; + + case 'anonymous': + if (($auths['anonymous'] ?? true) === false) { + throw new Exception('Anonymous authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); + } + break; + + case 'invites': + if (($auths['invites'] ?? true) === false) { + throw new Exception('Invites authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); + } + break; + + case 'jwt': + if (($auths['JWT'] ?? true) === false) { + throw new Exception('JWT authentication is disabled for this project', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); + } + break; + + default: + throw new Exception('Unsupported authentication route', 501, Exception::USER_AUTH_METHOD_UNSUPPORTED); + break; + } + }); + +App::shutdown() + ->groups(['api']) + ->inject('utopia') + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('events') + ->inject('audits') + ->inject('usage') + ->inject('deletes') + ->inject('database') + ->inject('mode') + ->inject('dbForProject') + ->action(function (App $utopia, Request $request, Response $response, Document $project, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject) { + + if (!empty($events->getEvent())) { + if (empty($events->getPayload())) { + $events->setPayload($response->getPayload()); + } + /** + * Trigger functions. + */ + $events + ->setClass(Event::FUNCTIONS_CLASS_NAME) + ->setQueue(Event::FUNCTIONS_QUEUE_NAME) + ->trigger(); + + /** + * Trigger webhooks. + */ + $events + ->setClass(Event::WEBHOOK_CLASS_NAME) + ->setQueue(Event::WEBHOOK_QUEUE_NAME) + ->trigger(); + + /** + * Trigger realtime. + */ + if ($project->getId() !== 'console') { + $allEvents = Event::generateEvents($events->getEvent(), $events->getParams()); + $payload = new Document($events->getPayload()); + + $db = $events->getContext('database'); + $collection = $events->getContext('collection'); + $bucket = $events->getContext('bucket'); + + $target = Realtime::fromPayload( + // Pass first, most verbose event pattern + event: $allEvents[0], + payload: $payload, + project: $project, + database: $db, + collection: $collection, + bucket: $bucket, + ); + + Realtime::send( + projectId: $target['projectId'] ?? $project->getId(), + payload: $events->getPayload(), + events: $allEvents, + channels: $target['channels'], + roles: $target['roles'], + options: [ + 'permissionsChanged' => $target['permissionsChanged'], + 'userId' => $events->getParam('userId') + ] + ); + } + } + + if (!empty($audits->getResource())) { + foreach ($events->getParams() as $key => $value) { + $audits->setParam($key, $value); + } + $audits->trigger(); + } + + if (!empty($deletes->getType())) { + $deletes->trigger(); + } + + if (!empty($database->getType())) { + $database->trigger(); + } + + $route = $utopia->match($request); + if ( + App::getEnv('_APP_USAGE_STATS', 'enabled') == 'enabled' + && $project->getId() + && $mode !== APP_MODE_ADMIN // TODO: add check to make sure user is admin + && !empty($route->getLabel('sdk.namespace', null)) + ) { // Don't calculate console usage on admin mode + $usage + ->setParam('networkRequestSize', $request->getSize() + $usage->getParam('storage')) + ->setParam('networkResponseSize', $response->getSize()) + ->submit(); + } + }); diff --git a/app/controllers/shared/web.php b/app/controllers/shared/web.php index a3f2df012c..5e1e0bd370 100644 --- a/app/controllers/shared/web.php +++ b/app/controllers/shared/web.php @@ -6,54 +6,59 @@ use Appwrite\Utopia\Response; use Appwrite\Utopia\Request; use Appwrite\Utopia\View; -App::init(function (App $utopia, Request $request, Response $response, View $layout) { +App::init() + ->groups(['web']) + ->inject('utopia') + ->inject('request') + ->inject('response') + ->inject('layout') + ->action(function (App $utopia, Request $request, Response $response, View $layout) { + /* AJAX check */ + if (!empty($request->getQuery('version', ''))) { + $layout->setPath(__DIR__ . '/../../views/layouts/empty.phtml'); + } - /* AJAX check */ - if (!empty($request->getQuery('version', ''))) { - $layout->setPath(__DIR__ . '/../../views/layouts/empty.phtml'); - } + $port = $request->getPort(); + $protocol = $request->getProtocol(); + $domain = $request->getHostname(); - $port = $request->getPort(); - $protocol = $request->getProtocol(); - $domain = $request->getHostname(); + $layout + ->setParam('title', APP_NAME) + ->setParam('protocol', $protocol) + ->setParam('domain', $domain) + ->setParam('endpoint', $protocol . '://' . $domain . ($port != 80 && $port != 443 ? ':' . $port : '')) + ->setParam('home', App::getEnv('_APP_HOME')) + ->setParam('setup', App::getEnv('_APP_SETUP')) + ->setParam('class', 'unknown') + ->setParam('icon', '/images/favicon.png') + ->setParam('roles', [ + ['type' => 'owner', 'label' => 'Owner'], + ['type' => 'developer', 'label' => 'Developer'], + ['type' => 'admin', 'label' => 'Admin'], + ]) + ->setParam('runtimes', Config::getParam('runtimes')) + ->setParam('mode', App::getMode()) + ; - $layout - ->setParam('title', APP_NAME) - ->setParam('protocol', $protocol) - ->setParam('domain', $domain) - ->setParam('endpoint', $protocol . '://' . $domain . ($port != 80 && $port != 443 ? ':' . $port : '')) - ->setParam('home', App::getEnv('_APP_HOME')) - ->setParam('setup', App::getEnv('_APP_SETUP')) - ->setParam('class', 'unknown') - ->setParam('icon', '/images/favicon.png') - ->setParam('roles', [ - ['type' => 'owner', 'label' => 'Owner'], - ['type' => 'developer', 'label' => 'Developer'], - ['type' => 'admin', 'label' => 'Admin'], - ]) - ->setParam('runtimes', Config::getParam('runtimes')) - ->setParam('mode', App::getMode()) - ; + $time = (60 * 60 * 24 * 45); // 45 days cache - $time = (60 * 60 * 24 * 45); // 45 days cache + $response + ->addHeader('Cache-Control', 'public, max-age=' . $time) + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT') // 45 days cache + ->addHeader('X-Frame-Options', 'SAMEORIGIN') // Avoid console and homepage from showing in iframes + ->addHeader('X-XSS-Protection', '1; mode=block; report=/v1/xss?url=' . \urlencode($request->getURI())) + ->addHeader('X-UA-Compatible', 'IE=Edge') // Deny IE browsers from going into quirks mode + ; - $response - ->addHeader('Cache-Control', 'public, max-age=' . $time) - ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT') // 45 days cache - ->addHeader('X-Frame-Options', 'SAMEORIGIN') // Avoid console and homepage from showing in iframes - ->addHeader('X-XSS-Protection', '1; mode=block; report=/v1/xss?url=' . \urlencode($request->getURI())) - ->addHeader('X-UA-Compatible', 'IE=Edge') // Deny IE browsers from going into quirks mode - ; + $route = $utopia->match($request); - $route = $utopia->match($request); + $route->label('error', __DIR__ . '/../../views/general/error.phtml'); - $route->label('error', __DIR__ . '/../../views/general/error.phtml'); + $scope = $route->getLabel('scope', ''); - $scope = $route->getLabel('scope', ''); - - $layout - ->setParam('version', App::getEnv('_APP_VERSION', 'UNKNOWN')) - ->setParam('isDev', App::isDevelopment()) - ->setParam('class', $scope) - ; -}, ['utopia', 'request', 'response', 'layout'], 'web'); + $layout + ->setParam('version', App::getEnv('_APP_VERSION', 'UNKNOWN')) + ->setParam('isDev', App::isDevelopment()) + ->setParam('class', $scope) + ; + }); diff --git a/app/controllers/web/console.php b/app/controllers/web/console.php index 410be4d307..e627ff986e 100644 --- a/app/controllers/web/console.php +++ b/app/controllers/web/console.php @@ -9,31 +9,36 @@ use Utopia\Domains\Domain; use Utopia\Database\Validator\UID; use Utopia\Storage\Storage; -App::init(function (View $layout) { +App::init() + ->groups(['console']) + ->inject('layout') + ->action(function (View $layout) { + $layout + ->setParam('description', 'Appwrite Console allows you to easily manage, monitor, and control your entire backend API and tools.') + ->setParam('analytics', 'UA-26264668-5') + ; + }); - $layout - ->setParam('description', 'Appwrite Console allows you to easily manage, monitor, and control your entire backend API and tools.') - ->setParam('analytics', 'UA-26264668-5') - ; -}, ['layout'], 'console'); +App::shutdown() + ->groups(['console']) + ->inject('response') + ->inject('layout') + ->action(function (Response $response, View $layout) { + $header = new View(__DIR__ . '/../../views/console/comps/header.phtml'); + $footer = new View(__DIR__ . '/../../views/console/comps/footer.phtml'); -App::shutdown(function (Response $response, View $layout) { + $footer + ->setParam('home', App::getEnv('_APP_HOME', '')) + ->setParam('version', App::getEnv('_APP_VERSION', 'UNKNOWN')) + ; - $header = new View(__DIR__ . '/../../views/console/comps/header.phtml'); - $footer = new View(__DIR__ . '/../../views/console/comps/footer.phtml'); + $layout + ->setParam('header', [$header]) + ->setParam('footer', [$footer]) + ; - $footer - ->setParam('home', App::getEnv('_APP_HOME', '')) - ->setParam('version', App::getEnv('_APP_VERSION', 'UNKNOWN')) - ; - - $layout - ->setParam('header', [$header]) - ->setParam('footer', [$footer]) - ; - - $response->html($layout->render()); -}, ['response', 'layout'], 'console'); + $response->html($layout->render()); + }); App::get('/error/:code') ->groups(['web', 'console']) diff --git a/app/controllers/web/home.php b/app/controllers/web/home.php index b5bf366066..90828a1d54 100644 --- a/app/controllers/web/home.php +++ b/app/controllers/web/home.php @@ -7,29 +7,34 @@ use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; -App::init(function (View $layout) { +App::init() + ->groups(['home']) + ->inject('layout') + ->action(function (View $layout) { + $header = new View(__DIR__ . '/../../views/home/comps/header.phtml'); + $footer = new View(__DIR__ . '/../../views/home/comps/footer.phtml'); - $header = new View(__DIR__ . '/../../views/home/comps/header.phtml'); - $footer = new View(__DIR__ . '/../../views/home/comps/footer.phtml'); + $footer + ->setParam('version', App::getEnv('_APP_VERSION', 'UNKNOWN')) + ; - $footer - ->setParam('version', App::getEnv('_APP_VERSION', 'UNKNOWN')) - ; + $layout + ->setParam('title', APP_NAME) + ->setParam('description', '') + ->setParam('class', 'home') + ->setParam('platforms', Config::getParam('platforms')) + ->setParam('header', [$header]) + ->setParam('footer', [$footer]) + ; + }); - $layout - ->setParam('title', APP_NAME) - ->setParam('description', '') - ->setParam('class', 'home') - ->setParam('platforms', Config::getParam('platforms')) - ->setParam('header', [$header]) - ->setParam('footer', [$footer]) - ; -}, ['layout'], 'home'); - -App::shutdown(function (Response $response, View $layout) { - - $response->html($layout->render()); -}, ['response', 'layout'], 'home'); +App::shutdown() + ->groups(['home']) + ->inject('response') + ->inject('layout') + ->action(function (Response $response, View $layout) { + $response->html($layout->render()); + }); App::get('/') ->groups(['web', 'home']) diff --git a/app/executor.php b/app/executor.php index 7bf42c98db..22d8552635 100644 --- a/app/executor.php +++ b/app/executor.php @@ -581,57 +581,64 @@ App::setResource('orchestrationPool', fn() => $orchestrationPool); App::setResource('activeRuntimes', fn() => $activeRuntimes); /** Set callbacks */ -App::error(function ($utopia, $error, $request, $response) { - $route = $utopia->match($request); - logError($error, "httpError", $route); +App::error() + ->inject('utopia') + ->inject('error') + ->inject('request') + ->inject('response') + ->action(function (App $utopia, throwable $error, Request $request, Response $response) { + $route = $utopia->match($request); + logError($error, "httpError", $route); - switch ($error->getCode()) { - case 400: // Error allowed publicly - case 401: // Error allowed publicly - case 402: // Error allowed publicly - case 403: // Error allowed publicly - case 404: // Error allowed publicly - case 406: // Error allowed publicly - case 409: // Error allowed publicly - case 412: // Error allowed publicly - case 425: // Error allowed publicly - case 429: // Error allowed publicly - case 501: // Error allowed publicly - case 503: // Error allowed publicly - $code = $error->getCode(); - break; - default: - $code = 500; // All other errors get the generic 500 server error status code - } + switch ($error->getCode()) { + case 400: // Error allowed publicly + case 401: // Error allowed publicly + case 402: // Error allowed publicly + case 403: // Error allowed publicly + case 404: // Error allowed publicly + case 406: // Error allowed publicly + case 409: // Error allowed publicly + case 412: // Error allowed publicly + case 425: // Error allowed publicly + case 429: // Error allowed publicly + case 501: // Error allowed publicly + case 503: // Error allowed publicly + $code = $error->getCode(); + break; + default: + $code = 500; // All other errors get the generic 500 server error status code + } - $output = [ - 'message' => $error->getMessage(), - 'code' => $error->getCode(), - 'file' => $error->getFile(), - 'line' => $error->getLine(), - 'trace' => $error->getTrace(), - 'version' => App::getEnv('_APP_VERSION', 'UNKNOWN') - ]; + $output = [ + 'message' => $error->getMessage(), + 'code' => $error->getCode(), + 'file' => $error->getFile(), + 'line' => $error->getLine(), + 'trace' => $error->getTrace(), + 'version' => App::getEnv('_APP_VERSION', 'UNKNOWN') + ]; - $response - ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') - ->addHeader('Expires', '0') - ->addHeader('Pragma', 'no-cache') - ->setStatusCode($code); + $response + ->addHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + ->addHeader('Expires', '0') + ->addHeader('Pragma', 'no-cache') + ->setStatusCode($code); - $response->json($output); -}, ['utopia', 'error', 'request', 'response']); + $response->json($output); + }); -App::init(function ($request, $response) { - $secretKey = $request->getHeader('x-appwrite-executor-key', ''); - if (empty($secretKey)) { - throw new Exception('Missing executor key', 401); - } +App::init() + ->inject('request') + ->action(function (Request $request) { + $secretKey = $request->getHeader('x-appwrite-executor-key', ''); + if (empty($secretKey)) { + throw new Exception('Missing executor key', 401); + } - if ($secretKey !== App::getEnv('_APP_EXECUTOR_SECRET', '')) { - throw new Exception('Missing executor key', 401); - } -}, ['request', 'response']); + if ($secretKey !== App::getEnv('_APP_EXECUTOR_SECRET', '')) { + throw new Exception('Missing executor key', 401); + } + }); $http->on('start', function ($http) {