diff --git a/app/config/errors.php b/app/config/errors.php index f9c2f6b5ba..2c1bda995d 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -546,6 +546,11 @@ return [ 'description' => 'Function runtime could not be detected.', 'code' => 400, ], + Exception::FUNCTION_EXECUTE_PERMISSION_MISSING => [ + 'name' => Exception::FUNCTION_EXECUTE_PERMISSION_MISSING, + 'description' => 'To execute function using domain, execute permissions must include "any" or "guests".', + 'code' => 401, + ], /** Sites */ Exception::SITE_NOT_FOUND => [ @@ -580,6 +585,11 @@ return [ 'description' => 'Build with the requested ID is already completed and cannot be canceled.', 'code' => 400, ], + Exception::BUILD_CANCELED => [ + 'name' => Exception::BUILD_CANCELED, + 'description' => 'Build with the requested ID has been canceled.', + 'code' => 400, + ], Exception::BUILD_FAILED => [ 'name' => Exception::BUILD_FAILED, 'description' => 'Build with the requested ID failed. Please check the logs for more information.', diff --git a/app/controllers/general.php b/app/controllers/general.php index 5c08918ace..d5feba15f6 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -58,8 +58,6 @@ Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) { - $utopia->getRoute()?->label('error', __DIR__ . '/../views/general/error.phtml'); - $host = $request->getHostname() ?? ''; if (!empty($previewHostname)) { $host = $previewHostname; @@ -77,24 +75,28 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw )[0] ?? new Document(); } + $errorView = __DIR__ . '/../views/general/error.phtml'; + $url = (System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https') . '://' . System::getEnv('_APP_DOMAIN', ''); + if ($rule->isEmpty()) { if ($host === System::getEnv('_APP_DOMAIN_FUNCTIONS', '') || $host === System::getEnv('_APP_DOMAIN_SITES', '')) { - throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'This domain cannot be used for security reasons. Please use any subdomain instead.'); + throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'This domain cannot be used for security reasons. Please use any subdomain instead.', view: $errorView); } if (\str_ends_with($host, System::getEnv('_APP_DOMAIN_FUNCTIONS', '')) || \str_ends_with($host, System::getEnv('_APP_DOMAIN_SITES', ''))) { - throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'This domain is not connected to any Appwrite resource yet. Please configure custom domain or function domain to allow this request.'); + $exception = new AppwriteException(AppwriteException::RULE_NOT_FOUND, 'This domain is not connected to any Appwrite resources. Visit domains tab under function/site settings to configure it.', view: $errorView); + + $exception->addCTA('Start with this domain', $url . '/console'); + throw $exception; } if (System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'disabled') === 'enabled') { if ($host !== 'localhost' && $host !== APP_HOSTNAME_INTERNAL && $host !== System::getEnv('_APP_CONSOLE_DOMAIN', '')) { - throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.'); + throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView); } } // Act as API - no Proxy logic - $utopia->getRoute()?->label('error', ''); - return false; } @@ -114,7 +116,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw if (array_key_exists('proxy', $project->getAttribute('services', []))) { $status = $project->getAttribute('services', [])['proxy']; if (!$status) { - throw new AppwriteException(AppwriteException::GENERAL_SERVICE_DISABLED); + throw new AppwriteException(AppwriteException::GENERAL_SERVICE_DISABLED, view: $errorView); } } @@ -130,7 +132,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw if (System::getEnv('_APP_OPTIONS_COMPUTE_FORCE_HTTPS', 'disabled') === 'enabled') { // Force HTTPS if ($request->getProtocol() !== 'https' && $request->getHostname() !== APP_HOSTNAME_INTERNAL) { if ($request->getMethod() !== Request::METHOD_GET) { - throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.'); + throw new AppwriteException(AppwriteException::GENERAL_PROTOCOL_UNSUPPORTED, 'Method unsupported over HTTP. Please use HTTPS instead.', view: $errorView); } return $response->redirect('https://' . $request->getHostname() . $request->getURI()); } @@ -139,6 +141,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw /** @var Database $dbForProject */ $dbForProject = $getProjectDB($project); + /** @var Document $deployment */ $deployment = Authorization::skip(fn () => $dbForProject->getDocument('deployments', $rule->getAttribute('deploymentId'))); if ($deployment->getAttribute('resourceType', '') === 'functions') { @@ -147,6 +150,15 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $type = 'site'; } + if ($deployment->isEmpty()) { + $resourceType = $rule->getAttribute('deploymentResourceType', ''); + $resourceId = $rule->getAttribute('deploymentResourceId', ''); + $type = ($resourceType === 'site') ? 'sites' : 'functions'; + $exception = new AppwriteException(AppwriteException::DEPLOYMENT_NOT_FOUND, view: $errorView); + $exception->addCTA('View deployments', $url . '/console/project-' . $projectId . '/' . $type . '/' . $resourceType . '-' . $resourceId); + throw $exception; + } + $resource = $type === 'function' ? Authorization::skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) : Authorization::skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', ''))); @@ -239,11 +251,15 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $requestHeaders = $request->getHeaders(); if ($resource->isEmpty() || !$resource->getAttribute('enabled')) { - throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND); + if ($type === 'functions') { + throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView); + } else { + throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView); + } } if ($isResourceBlocked($project, $type === 'function' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { - throw new AppwriteException(AppwriteException::GENERAL_RESOURCE_BLOCKED); + throw new AppwriteException(AppwriteException::GENERAL_RESOURCE_BLOCKED, view: $errorView); } $version = match ($type) { @@ -266,22 +282,40 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw } if (\is_null($runtime)) { - throw new AppwriteException(AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); + throw new AppwriteException(AppwriteException::FUNCTION_RUNTIME_UNSUPPORTED, 'Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported', view: $errorView); } $allowAnyStatus = !\is_null($apiKey) && $apiKey->isDeploymentStatusIgnored(); if (!$allowAnyStatus && $deployment->getAttribute('status') !== 'ready') { - if ($deployment->getAttribute('status') === 'failed') { - throw new AppwriteException(AppwriteException::BUILD_FAILED); - } else { - throw new AppwriteException(AppwriteException::BUILD_NOT_READY); + $status = $deployment->getAttribute('status'); + + switch ($status) { + case 'failed': + $exception = new AppwriteException(AppwriteException::BUILD_FAILED, view: $errorView); + $ctaUrl = '/console/project-' . $project->getId() . '/sites/site-' . $resource->getId() . '/deployments/deployment-' . $deployment->getId(); + $exception->addCTA('View logs', $url . $ctaUrl); + break; + case 'canceled': + $exception = new AppwriteException(AppwriteException::BUILD_CANCELED, view: $errorView); + $ctaUrl = '/console/project-' . $project->getId() . '/sites/site-' . $resource->getId() . '/deployments'; + $exception->addCTA('View deployments', $url . $ctaUrl); + break; + default: + $exception = new AppwriteException(AppwriteException::BUILD_NOT_READY, view: $errorView); + $ctaUrl = '/console/project-' . $project->getId() . '/sites/site-' . $resource->getId() . '/deployments/deployment-' . $deployment->getId(); + $exception->addCTA('Reload', '/'); + $exception->addCTA('View logs', $url . $ctaUrl); + break; } + throw $exception; } if ($type === 'function') { $permissions = $resource->getAttribute('execute'); if (!(\in_array('any', $permissions)) && !(\in_array('guests', $permissions))) { - throw new AppwriteException(AppwriteException::USER_UNAUTHORIZED, 'To execute function using domain, execute permissions must include "any" or "guests"'); + $exception = new AppwriteException(AppwriteException::FUNCTION_EXECUTE_PERMISSION_MISSING, view: $errorView); + $exception->addCTA('View settings', $url . '/console/project-' . $project->getId() . '/functions/function-' . $resource->getId() . '/settings'); + throw $exception; } } @@ -504,6 +538,24 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw } } + // Branded error pages (when developer left body empty) + if ($executionResponse['statusCode'] >= 400 && empty($executionResponse['body'])) { + $layout = new View($errorView); + $layout + ->setParam('title', $project->getAttribute('name') . ' - Error') + ->setParam('type', 'proxy_error_override') + ->setParam('code', $executionResponse['statusCode']); + + $executionResponse['body'] = $layout->render(); + foreach ($executionResponse['headers'] as $key => $value) { + if (\strtolower($key) === 'content-length') { + $executionResponse['headers'][$key] = \strlen($executionResponse['body']); + } elseif (\strtolower($key) === 'content-type') { + $executionResponse['headers'][$key] = 'text/html'; + } + } + } + $headersFiltered = []; foreach ($executionResponse['headers'] as $key => $value) { if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) { @@ -646,7 +698,6 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw return true; } elseif ($type === 'api') { - $utopia->getRoute()?->label('error', ''); return false; } elseif ($type === 'redirect') { $url = $rule->getAttribute('redirectUrl', ''); @@ -659,10 +710,9 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $response->redirect($url, \intval($rule->getAttribute('redirectStatusCode', 301))); return true; } else { - throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown resource type ' . $type); + throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown resource type ' . $type, view: $errorView); } - $utopia->getRoute()?->label('error', ''); return false; } @@ -1257,9 +1307,14 @@ App::error() ->addHeader('Pragma', 'no-cache') ->setStatusCode($code); - $template = ($route) ? $route->getLabel('error', null) : null; + $template = $error->getView() ?? (($route) ? $route->getLabel('error', null) : null); - if ($template) { + // TODO: Ideally use group 'api' here, but all wildcard routes seem to have 'api' at the moment + if (!\str_starts_with($route->getPath(), '/v1')) { + $template = __DIR__ . '/../views/general/error.phtml'; + } + + if (!empty($template)) { $layout = new View($template); $layout @@ -1270,7 +1325,8 @@ App::error() ->setParam('message', $output['message'] ?? '') ->setParam('type', $output['type'] ?? '') ->setParam('code', $output['code'] ?? '') - ->setParam('trace', $output['trace'] ?? []); + ->setParam('trace', $output['trace'] ?? []) + ->setParam('exception', $error); $response->html($layout->render()); return; diff --git a/app/views/general/error.phtml b/app/views/general/error.phtml index a3715e0156..60afe86494 100644 --- a/app/views/general/error.phtml +++ b/app/views/general/error.phtml @@ -2,21 +2,98 @@ $development = $this->getParam('development', false); $type = $this->getParam('type', 'general_server_error'); $code = $this->getParam('code', 500); -$errorID = $this->getParam('errorID', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'); $message = $this->getParam('message', ''); $trace = $this->getParam('trace', []); -$projectName = $this->getParam('projectName', ''); -$projectURL = $this->getParam('projectURL', ''); -$title = $this->getParam('title', '') +$title = $this->getParam('title', 'Error'); +$exception = $this->getParam('exception', null); + +$isSimpleMessage = true; +$label = ''; +$labelClass = ''; +$buttons = []; + +if($exception !== null && method_exists($exception, 'getCTAs')) { + foreach ($exception->getCTAs() as $index => $cta) { + $class = ($index === 0) ? 'bordered-button' : 'button'; + + $buttons[] = [ + 'text' => $cta['label'], + 'url' => $cta['url'], + 'class' => $class + ]; + } +} + +switch ($type) { + case 'proxy_error_override': + $type = ''; + $label = 'Error ' . $code; + + $message = $code >= 500 ? 'An unexpected server error occured.' : 'An unexpected client error occured.'; + switch($code) { + case 401: + $message = 'You must sign in to access this page.'; + break; + case 403: + $message = 'You are not authorized to access this page.'; + break; + case 404: + $message = 'The page you are looking for does not exist.'; + break; + case 504: + $message = 'The server did not respond in time.'; + break; + case 501: + $message = 'This page is not implemented yet.'; + break; + } + + break; + case 'function_execute_permission_missing': + $label = 'Execution not permitted'; + $labelClass = 'warning'; + break; + case 'build_not_ready': + $label = 'Deployment is still building'; + $message = 'The page will update after the build completes.'; + $labelClass = 'warning'; + break; + case 'build_failed': + $label = 'Deployment build failed'; + $message = 'An error occurred during the build process.'; + $labelClass = 'error'; + break; + case 'rule_not_found': + $label = 'Nothing is here yet'; + $message = 'This page is empty, but you can make it yours.'; + break; + case 'deployment_not_found': + $label = 'No active deployments'; + $message = 'This page is empty, activate a deployment to make it live.'; + break; + case 'build_canceled': + $label = 'Deployment build canceled'; + $message = 'This build was canceled and won\'t be deployed.'; + break; + case 'general_route_not_found': + $label = 'Page not found'; + $message = 'The page you\'re looking for doesn\'t exist.'; + break; + default: + $label = 'Error ' . $code; + $message = $message; + $isSimpleMessage = false; + break; +} ?> - - - + + + getParam('title', '') as="font" type="font/woff2" crossorigin /> - - - - - - - - - - <?php echo $this->print($title); ?> - -
-
-
-

Error print($code); ?>

-

print($message); ?>

-
-

Type

-

print($type); ?>

+ +
+
+
print($label); ?>
+

print($message); ?>

+ +
+ print($type); ?>
- -

Error Trace

- -
-
- - - $value) : ?> - - - - - - - -
print($key, self::FILTER_ESCAPE); ?> - - -
print(var_export($value, true), self::FILTER_ESCAPE); ?>
- -
print($value, self::FILTER_ESCAPE); ?>
- -
-
-
-
+ +
+ + + + + + + + +
-
+
+ +
+ +
Error trace
+ $traceItem): ?> +
+ +
file
+
print($traceItem['file']); ?>
+ + + +
line
+
print($traceItem['line']); ?>
+ + + +
function
+
print($traceItem['function']); ?>
+ + + +
args
+
print(\var_export($traceItem['args'], true)); ?>
+ +
+ +
+ + +
+

Powered by

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
- \ No newline at end of file diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 338da29403..f8f7b9ac6c 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -165,6 +165,7 @@ class Exception extends \Exception public const FUNCTION_SYNCHRONOUS_TIMEOUT = 'function_synchronous_timeout'; public const FUNCTION_TEMPLATE_NOT_FOUND = 'function_template_not_found'; public const FUNCTION_RUNTIME_NOT_DETECTED = 'function_runtime_not_detected'; + public const FUNCTION_EXECUTE_PERMISSION_MISSING = 'function_execute_permission_missing'; /** Deployments */ public const DEPLOYMENT_NOT_FOUND = 'deployment_not_found'; @@ -174,6 +175,7 @@ class Exception extends \Exception public const BUILD_NOT_READY = 'build_not_ready'; public const BUILD_IN_PROGRESS = 'build_in_progress'; public const BUILD_ALREADY_COMPLETED = 'build_already_completed'; + public const BUILD_CANCELED = 'build_canceled'; public const BUILD_FAILED = 'build_failed'; /** Execution */ @@ -320,11 +322,14 @@ class Exception extends \Exception protected string $type = ''; protected array $errors = []; protected bool $publish; + private array $ctas = []; + private ?string $view = null; - public function __construct(string $type = Exception::GENERAL_UNKNOWN, string $message = null, int|string $code = null, \Throwable $previous = null) + public function __construct(string $type = Exception::GENERAL_UNKNOWN, string $message = null, int|string $code = null, \Throwable $previous = null, ?string $view = null) { $this->errors = Config::getParam('errors'); $this->type = $type; + $this->view = $view; $this->code = $code ?? $this->errors[$type]['code']; // Mark string errors like HY001 from PDO as 500 errors @@ -374,4 +379,23 @@ class Exception extends \Exception { return $this->publish; } + + public function addCTA(string $label, ?string $url = null): self + { + $this->ctas[] = [ + 'label' => $label, + 'url' => $url + ]; + return $this; + } + + public function getCTAs(): array + { + return $this->ctas; + } + + public function getView(): ?string + { + return $this->view; + } } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index bee774be08..f2de2c7737 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -2177,4 +2177,180 @@ class FunctionsCustomServerTest extends Scope $this->cleanupFunction($functionId); } + + public function testErrorPages(): void + { + // non-existent domain + $domain = 'non-existent-page.functions.localhost'; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/'); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString('Nothing is here yet', $response['body']); + $this->assertStringContainsString('Start with this domain', $response['body']); + + // failed deployment + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Error Pages', + 'runtime' => 'php-8.0', + 'entrypoint' => 'index.php', + 'timeout' => 15, + 'commands' => 'cd non-existing-directory', + 'execute' => ['any'] + ]); + + $domain = $this->setupFunctionDomain($functionId); + $proxyClient->setEndpoint('http://' . $domain); + + $deployment = $this->createDeployment($functionId, [ + 'entrypoint' => 'index.php', + 'code' => $this->packageFunction('php'), + 'activate' => true + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + + $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString('No active deployments', $response['body']); + $this->assertStringContainsString('View deployments', $response['body']); + + // canceled deployment + $deployment = $this->createDeployment($functionId, [ + 'entrypoint' => 'index.php', + 'code' => $this->packageFunction('php'), + 'activate' => true + ]); + + $deploymentId = $deployment['body']['$id'] ?? ''; + $this->assertEquals(202, $deployment['headers']['status-code']); + + $deployment = $this->cancelDeployment($functionId, $deploymentId); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('canceled', $deployment['body']['status']); + + $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString('No active deployments', $response['body']); + $this->assertStringContainsString('View deployments', $response['body']); + + $this->cleanupFunction($functionId); + } + + public function testErrorPagesPermissions(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Error Pages', + 'runtime' => 'php-8.0', + 'entrypoint' => 'index.php', + 'timeout' => 15, + 'commands' => '', + 'execute' => ['users'] + ]); + + $domain = $this->setupFunctionDomain($functionId); + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $deploymentId = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('php'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentId); + + $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertStringContainsString('Execution not permitted', $response['body']); + $this->assertStringContainsString('View settings', $response['body']); + + $this->cleanupFunction($functionId); + } + + public function testErrorPagesEmptyBody(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Error Pages', + 'runtime' => 'php-8.0', + 'entrypoint' => 'index.php', + 'timeout' => 15, + 'commands' => '', + 'execute' => ['any'] + ]); + + $domain = $this->setupFunctionDomain($functionId); + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $deploymentId = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('php'), + 'activate' => true + ]); + $this->assertNotEmpty($deploymentId); + + $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=404', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString('Error 404', $response['body']); + $this->assertStringContainsString('does not exist', $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=504', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + $this->assertEquals(504, $response['headers']['status-code']); + $this->assertStringContainsString('Error 504', $response['body']); + $this->assertStringContainsString('respond in time', $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=400', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString('Error 400', $response['body']); + $this->assertStringContainsString('unexpected client error', $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=500', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + $this->assertEquals(500, $response['headers']['status-code']); + $this->assertStringContainsString('Error 500', $response['body']); + $this->assertStringContainsString('unexpected server error', $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=400&body=CustomError400', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString('CustomError400', $response['body']); + + $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=500&body=CustomError500', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ])); + $this->assertEquals(500, $response['headers']['status-code']); + $this->assertStringContainsString('CustomError500', $response['body']); + + $this->cleanupFunction($functionId); + } } diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index ff7e4b283f..00edcc1b72 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -49,7 +49,7 @@ trait SitesBase 'x-appwrite-key' => $this->getProject()['apiKey'], ])); $this->assertEquals('ready', $deployment['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT)); - }, 100000, 500); + }, 150000, 500); // Not === so multipart/form-data works fine too if (($params['activate'] ?? false) == true) { diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 0b3b1710c1..e5f88461b2 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -1655,8 +1655,8 @@ class SitesCustomServerTest extends Scope $response = $proxyClient->call(Client::METHOD_GET, '/'); - $this->assertEquals(401, $response['headers']['status-code']); - $this->assertStringContainsString("This domain is not connected to any Appwrite resource yet", $response['body']); + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString("This page is empty, but you can make it yours.", $response['body']); $site = $this->createSite([ 'siteId' => ID::unique(), @@ -1707,8 +1707,8 @@ class SitesCustomServerTest extends Scope $siteDomain = $this->setupSiteDomain($siteId); $this->assertNotEmpty($siteDomain); - $delpoymentDomain = $this->getDeploymentDomain($deploymentId); - $this->assertNotEmpty($delpoymentDomain); + $deploymentDomain = $this->getDeploymentDomain($deploymentId); + $this->assertNotEmpty($deploymentDomain); $proxyClient = new Client(); $proxyClient->setEndpoint('http://' . $siteDomain); @@ -1719,7 +1719,7 @@ class SitesCustomServerTest extends Scope $contentLength = $response['headers']['content-length']; $proxyClient = new Client(); - $proxyClient->setEndpoint('http://' . $delpoymentDomain); + $proxyClient->setEndpoint('http://' . $deploymentDomain); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringContainsString('/console/auth/preview', $response['headers']['location']); @@ -2435,7 +2435,7 @@ class SitesCustomServerTest extends Scope }, 100000, 500); $response = $proxyClient->call(Client::METHOD_GET, '/'); - $this->assertStringContainsString('build_failed', $response['body']); + $this->assertStringContainsString('This page is empty, activate a deployment to make it live.', $response['body']); $this->cleanupSite($siteId); } @@ -2505,6 +2505,113 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + public function testErrorPages(): void + { + // non-existent domain page + $domain = 'non-existent-page.sites.localhost'; + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/'); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString('Nothing is here yet', $response['body']); + $this->assertStringContainsString('Start with this domain', $response['body']); + + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Static site', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'outputDirectory' => './', + 'buildCommand' => 'sleep 5 && cd non-existing-directory', + ]); + $this->assertNotEmpty($siteId); + + $domain = $this->setupSiteDomain($siteId); + + // test canceled deployment error page + $deployment = $this->createDeployment($siteId, [ + 'code' => $this->packageSite('static'), + 'activate' => 'true' + ]); + $deploymentId = $deployment['body']['$id'] ?? ''; + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deployment = $this->cancelDeployment($siteId, $deploymentId); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('canceled', $deployment['body']['status']); + + $deploymentDomain = $this->getDeploymentDomain($deploymentId); + $this->assertNotEmpty($deploymentDomain); + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $deploymentDomain); + $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0); + $apiKey = $jwtObj->encode([ + 'projectCheckDisabled' => true, + 'previewAuthDisabled' => true, + ]); + + $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ + 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + ]); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString("Deployment build canceled", $response['body']); + $this->assertStringContainsString("View deployments", $response['body']); + + // check site domain for no active deployments + $proxyClient->setEndpoint('http://' . $domain); + $response = $proxyClient->call(Client::METHOD_GET, '/'); + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertStringContainsString('No active deployments', $response['body']); + $this->assertStringContainsString('View deployments', $response['body']); + + $deployment = $this->createDeployment($siteId, [ + 'code' => $this->packageSite('astro'), + 'activate' => 'true' + ]); + + $deploymentId = $deployment['body']['$id'] ?? ''; + $this->assertNotEmpty($deploymentId); + + $deploymentDomain = $this->getDeploymentDomain($deploymentId); + $this->assertNotEmpty($deploymentDomain); + + $proxyClient->setEndpoint('http://' . $deploymentDomain); + $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + + // deployment is still building error page + $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ + 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + ]); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString("Deployment is still building", $response['body']); + $this->assertStringContainsString("View logs", $response['body']); + $this->assertStringContainsString("Reload", $response['body']); + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + + $this->assertEquals('failed', $deployment['body']['status']); + }, 50000, 500); + + // deployment failed error page + $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ + 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + ]); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString("Deployment build failed", $response['body']); + $this->assertStringContainsString("View logs", $response['body']); + + $this->cleanupSite($siteId); + } + public function testEmptySiteSource(): void { $siteId = $this->setupSite([ diff --git a/tests/resources/functions/php/index.php b/tests/resources/functions/php/index.php index 27a9418b3c..6c67f27ee1 100644 --- a/tests/resources/functions/php/index.php +++ b/tests/resources/functions/php/index.php @@ -1,6 +1,12 @@ req->path === '/custom-response') { + $code = (int) ($context->req->query['code'] ?? '200'); + $body = $context->req->query['body'] ?? ''; + return $context->res->send($body, $code); + } + $context->log('body-is-' . ($context->req->body ?? '')); $context->log('custom-header-is-' . ($context->req->headers['x-custom-header'] ?? '')); $context->log('method-is-' . \strtolower($context->req->method ?? ''));