Merge pull request #9606 from appwrite/feat-update-error-pages

Design nice looking error pages
This commit is contained in:
Matej Bačo 2025-04-15 15:51:21 +02:00 committed by GitHub
commit 9893f77e6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 902 additions and 121 deletions

View file

@ -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.',

View file

@ -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;

View file

@ -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;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<link rel="icon" href="/favicon.png" />
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" defer></script>
<link
rel="preload"
href="/fonts/inter/inter-v8-latin-600.woff2"
@ -29,102 +106,427 @@ $title = $this->getParam('title', '')
as="font"
type="font/woff2"
crossorigin />
<link
rel="preload"
href="/fonts/poppins/poppins-v19-latin-500.woff2"
as="font"
type="font/woff2"
crossorigin />
<link
rel="preload"
href="/fonts/poppins/poppins-v19-latin-600.woff2"
as="font"
type="font/woff2"
crossorigin />
<link
rel="preload"
href="/fonts/poppins/poppins-v19-latin-700.woff2"
as="font"
type="font/woff2"
crossorigin />
<link
rel="preload"
href="/fonts/source-code-pro/source-code-pro-v20-latin-regular.woff2"
as="font"
type="font/woff2"
crossorigin />
<link rel="stylesheet" href="https://unpkg.com/@appwrite.io/pink" />
<link rel="preload" as="style" type="text/css" href="/fonts/main.css" />
<link rel="stylesheet" href="/fonts/main.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="content-security-policy" content="">
<title><?php echo $this->print($title); ?></title>
<style>
@media(min-width:768px) {
article.card {
padding: 2rem !important;
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400);
* {
margin: 0;
padding: 0;
}
body {
background-color: #FFFFFF;
}
.main {
display: flex;
min-height: 100vh;
width: 100vw;
align-items: center;
justify-content: center;
}
.content {
margin-left: auto;
margin-right: auto;
max-width: 400px;
}
span {
padding: var(--space-1, 2px) var(--space-3, 6px);
border-radius: var(--border-radius-XS, 6px);
background: var(--color-overlay-on-neutral, rgba(0, 0, 0, 0.06));
color: var(--color-fgColor-neutral-secondary, #56565C);
text-align: center;
font-family: var(--font-family-sansSerif, Inter), sans-serif;
font-size: var(--font-size-S, 14px);
font-style: normal;
font-weight: 400;
line-height: 140%;
letter-spacing: -0.063px;
}
h1 {
color: var(--color-fgColor-neutral-primary, #2D2D31);
text-align: center;
font-family: var(--font-family-sansSerif, Inter), sans-serif;
font-size: var(--font-size-XXXL, 32px);
font-style: normal;
font-weight: 400;
line-height: 140%;
letter-spacing: -0.144px;
margin-top: 8px;
margin-bottom: 32px;
}
.content h1 {
margin-bottom: 20px;
}
.content.small-error h1 {
font-size: var(--font-size-M, 20px);
}
.content.large-error h1 {
font-size: var(--font-size-XXXL, 32px);
}
.bordered-button {
border-radius: var(--border-radius-S, 8px);
font-family: var(--font-family-sansSerif, Inter), sans-serif;
font-size: var(--font-size-S, 14px);
font-style: normal;
font-weight: 500;
line-height: 140%;
letter-spacing: -0.063px;
padding: var(--space-3, 6px) var(--space-5, 10px);
cursor: pointer;
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #D8D8DB);
background: var(--color-bgColor-neutral-primary, #FFF);
color: var(--color-fgColor-neutral-secondary, #56565C);
}
button {
border-radius: var(--border-radius-S, 8px);
font-family: var(--font-family-sansSerif, Inter), sans-serif;
font-size: var(--font-size-S, 14px);
font-style: normal;
font-weight: 500;
line-height: 140%;
letter-spacing: -0.063px;
padding: var(--space-3, 6px) var(--space-5, 10px);
cursor: pointer;
border: var(--border-width-S, 1px) solid transparent;
background: var(--color-bgColor-neutral-primary, #FFF);
color: var(--color-fgColor-neutral-secondary, #56565C);
}
.center {
display: flex;
justify-content: center;
gap: 8px;
}
.brand {
position: absolute;
width: 100%;
bottom: 32px;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.brand p {
font-family: var(--font-family-monospace, "Fira Code"), monospace;
font-size: var(--font-size-XS, 12px);
font-style: normal;
font-weight: 400;
line-height: 130%;
letter-spacing: 0.96px;
text-transform: uppercase;
color: var(--color-fgColor-neutral-secondary, #56565C);
}
.brand svg {
height: 20px;
}
.warning {
background: var(--color-overlay-on-neutral, rgba(254, 124, 67, 0.16));
color: var(--color-fgColor-neutral-secondary, #61250A);
}
.error {
background: var(--color-overlay-on-neutral, rgba(255, 69, 58, 0.16));
color: var(--color-fgColor-neutral-secondary, #B31212);
}
.logo-dark {
display: none;
}
.logo-light {
display: block;
}
.type {
padding: var(--space-1, 2px) var(--space-3, 6px);
border-radius: var(--border-radius-XS, 6px);
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #EDEDF0);
background: var(--color-overlay-on-neutral, rgba(250, 250, 251, 1));
color: var(--color-fgColor-neutral-secondary, #56565C);
text-align: center;
font-family: var(--font-family-monospace, "Fira Code"), monospace;
font-size: var(--font-size-XS, 12px);
font-style: normal;
font-weight: 400;
line-height: 140%;
letter-spacing: 0px;
}
.error-trace {
max-width: 900px;
padding: 20px;
font-family: var(--font-family-sansSerif, Inter), sans-serif;
}
.back-button {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
color: var(--color-fgColor-neutral-secondary, #56565C);
font-family: var(--font-family-sansSerif, Inter), sans-serif;
font-size: var(--font-size-S, 14px);
font-style: normal;
font-weight: 500;
line-height: 140%;
letter-spacing: -0.45px;
}
.back-button:hover {
text-decoration: underline;
}
.trace-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 16px;
background: var(--color-bgColor-neutral-secondary, #FFFFFF);
padding: 10px 12px;
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #EDEDF0);
}
.trace-grid-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: var(--color-bgColor-neutral-secondary, #FAFAFB);
border-radius: 8px 8px 0 0;
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #EDEDF0);
font-family: var(--font-family-sansSerif, Inter), sans-serif;
font-size: var(--font-size-S, 14px);
font-weight: 400;
line-height: 140%;
letter-spacing: -0.45px;
color: var(--color-fgColor-neutral-secondary, #56565C);
}
.trace-label {
font-family: var(--font-family-sansSerif, Inter), sans-serif;
font-size: var(--font-size-S, 14px);
font-weight: 400;
line-height: 140%;
letter-spacing: -0.45px;
color: var(--color-fgColor-neutral-secondary, #56565C);
}
.trace-value {
color: var(--color-fgColor-neutral-secondary, #56565C);
font-family: var(--font-family-monospace, "Fira Code"), monospace;
font-size: var(--font-size-S, 14px);
font-weight: 400;
line-height: 140%;
letter-spacing: 0px;
}
.trace-args {
/* grid-column: 1 / -1; */
padding: 10px 12px;
/* white-space: pre-wrap; */
overflow-x: auto;
font-family: var(--font-family-monospace, "Fira Code"), monospace;
font-size: var(--font-size-S, 14px);
font-weight: 400;
line-height: 140%;
color: var(--color-fgColor-neutral-secondary, #56565C);
}
@media (max-width: 768px) {
.content {
margin-left: 16px;
margin-right: 16px;
}
h1 {
font-size: 28px;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1D1D21;
}
h1 {
color: var(--color-fgColor-neutral-primary, #EDEDF0);
}
span {
background: var(--color-overlay-on-neutral, rgba(255, 255, 255, 0.2));
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
.bordered-button {
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #414146);
background: var(--color-bgColor-neutral-primary, #1D1D21);
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
button {
background: var(--color-bgColor-neutral-primary, #1D1D21);
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
.brand p {
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
.warning {
background: var(--color-overlay-on-neutral, rgba(254, 124, 67, 0.24));
color: var(--color-fgColor-neutral-secondary, #FFD5C2);
}
.error {
background: var(--color-overlay-on-neutral, rgba(255, 69, 58, 0.28));
color: var(--color-fgColor-neutral-secondary, #FFD5D4);
}
.logo-light {
display: none;
}
.logo-dark {
display: block;
}
.type {
background: var(--color-overlay-on-neutral, rgba(25, 25, 28, 1));
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #414146);
}
.back-button {
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
.trace-grid {
background: var(--color-bgColor-neutral-secondary, #1D1D21);
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #2D2D31);
}
.trace-grid-header {
background: var(--color-bgColor-neutral-secondary, #19191C);
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #2D2D31);
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
.trace-label {
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
.trace-value {
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
.trace-args {
color: var(--color-fgColor-neutral-secondary, #C3C3C6);
}
}
</style>
</head>
<body>
<div class="container u-margin-block-start-24">
<article class="card u-padding-16">
<div class="u-flex u-flex-vertical u-gap-16">
<h1 class="heading-level-4 u-trim-1">Error <?php echo $this->print($code); ?></h1>
<p class="text"><?php echo $this->print($message); ?></p>
<div class="u-flex u-flex-vertical u-gap-8">
<p class="text">Type</p>
<p><code class="inline-code"><?php echo $this->print($type); ?></code></p>
<body x-data="{ page: 'error' }">
<div class="main">
<div x-show="page === 'error'" class="content <?php echo $isSimpleMessage ? 'large-error' : 'small-error' ?>">
<div class="center"><span class="<?php echo $this->print($labelClass); ?>"><?php echo $this->print($label); ?></span></div>
<h1><?php echo $this->print($message); ?></h1>
<?php if (!empty($type)): ?>
<div class="center">
<span class='type'><?php echo $this->print($type); ?></span>
</div>
<?php if ($development) : ?>
<h2 class="heading-level-5 u-trim-1">Error Trace</h2>
<?php foreach ($trace as $log) : ?>
<div class="table-with-scroll">
<div class="table-wrapper">
<table class="table is-remove-outer-styles">
<tbody class="table-tbody">
<?php foreach ($log as $key => $value) : ?>
<tr>
<td class="table-col" style="width: 120px"><?php echo $this->print($key, self::FILTER_ESCAPE); ?></td>
<td class="table-col"><code class="grid-code u-max-height-200 u-overflow-x-auto u-overflow-y-auto">
<?php if (is_array($value)) : ?>
<pre><?php echo $this->print(var_export($value, true), self::FILTER_ESCAPE); ?></pre>
<?php else : ?>
<pre><?php echo $this->print($value, self::FILTER_ESCAPE); ?></pre>
<?php endif; ?>
</code>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div class="center" style="margin-top: 20px;">
<?php if (!empty($buttons)): ?>
<?php foreach ($buttons as $button): ?>
<a href="<?php echo htmlspecialchars($button['url']); ?>">
<button class="<?php echo htmlspecialchars($button['class']); ?>">
<?php echo htmlspecialchars($button['text']); ?>
</button>
</a>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($development) : ?>
<button class="<?php echo count($buttons) === 0 ? 'bordered-button' : 'button' ?>" x-on:click="page = 'trace'">View error trace</button>
<?php endif; ?>
</div>
</article>
</div>
<div x-show="page === 'trace'" class="error-trace">
<button class="back-button" x-on:click="page = 'error'">
Back
</button>
<div class="trace-grid-header">Error trace</div>
<?php foreach ($trace as $index => $traceItem): ?>
<div class="trace-grid">
<?php if (isset($traceItem['file'])): ?>
<div class="trace-label">file</div>
<div class="trace-value"><?php echo $this->print($traceItem['file']); ?></div>
<?php endif; ?>
<?php if (isset($traceItem['line'])): ?>
<div class="trace-label">line</div>
<div class="trace-value"><?php echo $this->print($traceItem['line']); ?></div>
<?php endif; ?>
<?php if (isset($traceItem['function'])): ?>
<div class="trace-label">function</div>
<div class="trace-value"><?php echo $this->print($traceItem['function']); ?></div>
<?php endif; ?>
<?php if (isset($traceItem['args'])): ?>
<div class="trace-label">args</div>
<div class="trace-args"><pre><?php echo $this->print(\var_export($traceItem['args'], true)); ?></pre></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<div x-show="page === 'error'" class="brand">
<p>Powered by</p>
<svg class="logo-dark" width="110" height="20" viewBox="0 0 110 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.8649 16.2461C33.6492 16.2461 34.5511 15.3184 34.9433 14.6867H35.1197C35.1981 15.3578 35.6687 15.9895 36.5903 15.9895H38.3353V14.0156H37.8843C37.5706 14.0156 37.4138 13.838 37.4138 13.5617V5.64661H35.1001V6.90986H34.9236C34.4727 6.27823 33.5315 5.39001 31.8061 5.39001C29.0611 5.39001 27.022 7.67965 27.022 10.818C27.022 13.9564 29.1003 16.2461 31.8649 16.2461ZM32.2767 13.9959C30.6493 13.9959 29.3748 12.7919 29.3748 10.8378C29.3748 8.92316 30.6101 7.62044 32.2571 7.62044C33.8256 7.62044 35.1393 8.86395 35.1393 10.8378C35.1393 12.5945 34.0217 13.9959 32.2767 13.9959Z" fill="#EDEDF0" />
<path d="M39.7013 20H42.0149V14.6867H42.1914C42.6227 15.3184 43.5443 16.2461 45.3677 16.2461C48.1127 16.2461 50.1127 13.9169 50.1127 10.818C50.1127 7.69939 47.9755 5.39001 45.2109 5.39001C43.4462 5.39001 42.5835 6.35719 42.1718 6.89012H41.9953V5.63019H39.7013V20ZM44.8776 14.0551C43.2894 14.0551 41.9757 12.8708 41.9757 10.818C41.9757 9.06133 43.0933 7.58096 44.8383 7.58096C46.4657 7.58096 47.7402 8.86395 47.7402 10.818C47.7402 12.7326 46.5049 14.0551 44.8776 14.0551Z" fill="#EDEDF0" />
<path d="M51.3065 20H53.6202V14.6867H53.7966C54.228 15.3184 55.1495 16.2461 56.973 16.2461C59.718 16.2461 61.5273 13.9169 61.5273 10.818C61.5273 7.69939 59.5807 5.39001 56.8161 5.39001C55.0515 5.39001 54.1888 6.35719 53.777 6.89012H53.6005V5.64661H51.3065V20ZM56.4828 14.0551C54.8946 14.0551 53.5809 12.8708 53.5809 10.818C53.5809 9.06133 54.6985 7.58096 56.4436 7.58096C58.071 7.58096 59.3454 8.86395 59.3454 10.818C59.3454 12.7326 58.1102 14.0551 56.4828 14.0551Z" fill="#EDEDF0" />
<path d="M64.5857 16.2296H67.8601L69.7227 8.11721H69.8404L71.7031 16.2296H74.9579L77.5642 5.88678H75.2323L73.3697 14.0189H73.1932L71.3305 5.88678H68.2522L66.3699 14.0189H66.1935L64.3504 5.88678H61.8799L64.5857 16.2296Z" fill="#EDEDF0" />
<path d="M78.7363 16.2296H81.0499V11.1174C81.0499 9.16334 81.9519 7.9593 83.6381 7.9593H84.6576V5.63019H83.893C82.5793 5.63019 81.5793 6.53815 81.1872 7.40663H81.0303V5.88678H78.7363V16.2296Z" fill="#EDEDF0" />
<path d="M96.1391 16.2296H97.943V14.1571H96.1587C95.4529 14.1571 95.1588 13.8413 95.1588 13.111V7.93956H98.0606V5.88678H95.1588V2.98526H92.9628V5.88678H91.0413V7.93956H92.8255V13.1307C92.8255 15.3217 94.1392 16.2296 96.1391 16.2296Z" fill="#EDEDF0" />
<path d="M104.15 16.2461C106.287 16.2461 108.17 15.1802 108.836 13.0287L106.719 12.5155C106.346 13.6603 105.268 14.2525 104.13 14.2525C102.444 14.2525 101.327 13.1472 101.307 11.4102H109.091V10.7588C109.091 7.67965 107.189 5.39001 104.052 5.39001C101.287 5.39001 98.915 7.58096 98.915 10.8378C98.915 13.9959 101.013 16.2461 104.15 16.2461ZM101.327 9.71269C101.464 8.46918 102.581 7.42305 104.052 7.42305C105.464 7.42305 106.621 8.31128 106.738 9.71269H101.327Z" fill="#EDEDF0" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M90.0125 16.2296H87.6989V7.93956H85.895V5.88678H90.0125V16.2296Z" fill="#EDEDF0" />
<path d="M88.6834 4.45145C89.5265 4.45145 90.154 3.81983 90.154 2.99082C90.154 2.18155 89.5265 1.54993 88.6834 1.54993C87.8403 1.54993 87.2129 2.18155 87.2129 2.99082C87.2129 3.81983 87.8403 4.45145 88.6834 4.45145Z" fill="#EDEDF0" />
<path d="M20.2007 13.6935V18.258H8.88588C5.5894 18.258 2.71111 16.4222 1.17116 13.6935C0.947288 13.2968 0.751353 12.8806 0.586995 12.4486C0.26435 11.6021 0.0615332 10.6938 0 9.74603V8.51195C0.0133592 8.30074 0.03441 8.09119 0.0619381 7.88413C0.118209 7.45921 0.203222 7.04343 0.314953 6.63926C1.37195 2.80758 4.8089 0 8.88588 0C12.9629 0 16.3994 2.80758 17.4564 6.63926H12.6184C11.8241 5.39025 10.4493 4.5645 8.88588 4.5645C7.32245 4.5645 5.94767 5.39025 5.15341 6.63926C4.91132 7.01895 4.72349 7.43764 4.60042 7.88413C4.49112 8.27999 4.43282 8.69744 4.43282 9.12899C4.43282 10.4373 4.96962 11.6166 5.83027 12.4486C6.62778 13.2209 7.70299 13.6935 8.88588 13.6935H20.2007Z" fill="#FD366E" />
<path d="M20.2006 7.88412V12.4486H11.9414C12.8021 11.6166 13.3389 10.4373 13.3389 9.12899C13.3389 8.69744 13.2806 8.27999 13.1713 7.88412H20.2006Z" fill="#FD366E" />
</svg>
<svg class="logo-light" width="110" height="20" viewBox="0 0 110 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.8648 16.2461C33.649 16.2461 34.5509 15.3184 34.9431 14.6867H35.1195C35.198 15.3578 35.6685 15.9895 36.5901 15.9895H38.3351V14.0156H37.8841C37.5704 14.0156 37.4136 13.838 37.4136 13.5617V5.64661H35.0999V6.90986H34.9235C34.4725 6.27823 33.5314 5.39001 31.8059 5.39001C29.0609 5.39001 27.0218 7.67965 27.0218 10.818C27.0218 13.9564 29.1001 16.2461 31.8648 16.2461ZM32.2765 13.9959C30.6491 13.9959 29.3746 12.7919 29.3746 10.8378C29.3746 8.92316 30.6099 7.62044 32.2569 7.62044C33.8255 7.62044 35.1391 8.78499 35.1391 10.8378C35.1391 12.5945 34.0215 13.9959 32.2765 13.9959Z" fill="#2D2D31" />
<path d="M39.7011 20H42.0147V14.6867H42.1912C42.6226 15.3184 43.5441 16.2461 45.3676 16.2461C48.1126 16.2461 50.1125 13.9169 50.1125 10.818C50.1125 7.69939 47.9753 5.39001 45.2107 5.39001C43.4461 5.39001 42.5833 6.35719 42.1716 6.89012H41.9951V5.64661H39.7011V20ZM44.8774 14.0551C43.2892 14.0551 41.9755 12.8708 41.9755 10.818C41.9755 9.06133 43.0931 7.58096 44.8382 7.58096C46.4656 7.58096 47.74 8.86395 47.74 10.818C47.74 12.7326 46.5048 14.0551 44.8774 14.0551Z" fill="#2D2D31" />
<path d="M51.3063 20H53.62V14.6867H53.7964C54.2278 15.3184 55.1493 16.2461 56.9728 16.2461C59.7178 16.2461 61.5271 13.9169 61.5271 10.818C61.5271 7.69939 59.5805 5.39001 56.8159 5.39001C55.0513 5.39001 54.1886 6.35719 53.7768 6.89012H53.6004V5.64661H51.3063V20ZM56.4826 14.0551C54.8944 14.0551 53.5808 12.8708 53.5808 10.818C53.5808 9.06133 54.6984 7.58096 56.4434 7.58096C58.0708 7.58096 59.3453 8.86395 59.3453 10.818C59.3453 12.7326 58.11 14.0551 56.4826 14.0551Z" fill="#2D2D31" />
<path d="M64.5855 16.2296H67.8599L69.7226 8.11721H69.8402L71.7029 16.2296H74.9577L77.564 5.88678H75.2322L73.3695 14.0189H73.193L71.3303 5.88678H68.252L66.3697 14.0189H66.1933L64.3502 5.88678H61.8797L64.5855 16.2296Z" fill="#2D2D31" />
<path d="M78.7361 16.2296H81.0498V11.1174C81.0498 9.16334 81.9517 7.9593 83.6379 7.9593H84.6575V5.63019H83.8928C82.5791 5.63019 81.5791 6.53815 81.187 7.40663H81.0301V5.88678H78.7361V16.2296Z" fill="#2D2D31" />
<path d="M96.1389 16.2296H97.9428V14.1571H96.1585C95.4527 14.1571 95.1586 13.8413 95.1586 13.111V7.93956H98.0604V5.88678H95.1586V2.98526H92.9626V5.88678H91.0411V7.93956H92.8253V13.1307C92.8253 15.3217 94.139 16.2296 96.1389 16.2296Z" fill="#2D2D31" />
<path d="M104.15 16.2461C106.287 16.2461 108.169 15.1802 108.836 13.0287L106.718 12.5155C106.346 13.6603 105.268 14.2525 104.13 14.2525C102.444 14.2525 101.326 13.1472 101.307 11.4102H109.091V10.7588C109.091 7.67965 107.189 5.39001 104.052 5.39001C101.287 5.39001 98.9148 7.58096 98.9148 10.8378C98.9148 13.9959 101.013 16.2461 104.15 16.2461ZM101.326 9.71269C101.464 8.46918 102.581 7.42305 104.052 7.42305C105.464 7.42305 106.62 8.31128 106.738 9.71269H101.326Z" fill="#2D2D31" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M90.0123 16.2296H87.6987V7.93956H85.8948V5.88678H90.0123V16.2296Z" fill="#2D2D31" />
<path d="M88.6835 4.45145C89.5266 4.45145 90.154 3.81983 90.154 2.99082C90.154 2.18155 89.5266 1.54993 88.6835 1.54993C87.8404 1.54993 87.213 2.18155 87.213 2.99082C87.213 3.81983 87.8404 4.45145 88.6835 4.45145Z" fill="#2D2D31" />
<path d="M20.2007 13.6935V18.258H8.88588C5.5894 18.258 2.71111 16.4222 1.17116 13.6935C0.947288 13.2968 0.751353 12.8806 0.586995 12.4486C0.26435 11.6021 0.0615332 10.6938 0 9.74603V8.51195C0.0133592 8.30074 0.03441 8.09119 0.0619381 7.88413C0.118209 7.45921 0.203222 7.04343 0.314953 6.63926C1.37195 2.80758 4.8089 0 8.88588 0C12.9629 0 16.3994 2.80758 17.4564 6.63926H12.6184C11.8241 5.39025 10.4493 4.5645 8.88588 4.5645C7.32245 4.5645 5.94767 5.39025 5.15341 6.63926C4.91132 7.01895 4.72349 7.43764 4.60042 7.88413C4.49112 8.27999 4.43282 8.69744 4.43282 9.12899C4.43282 10.4373 4.96962 11.6166 5.83027 12.4486C6.62778 13.2209 7.70299 13.6935 8.88588 13.6935H20.2007Z" fill="#FD366E" />
<path d="M20.2007 7.88412V12.4486H11.9415C12.8022 11.6166 13.339 10.4373 13.339 9.12899C13.339 8.69744 13.2807 8.27999 13.1714 7.88412H20.2007Z" fill="#FD366E" />
</svg>
</div>
<script type="text/javascript">
const app = (JSON.parse(localStorage.getItem('appwrite')) ?? {});
const theme = app.theme ?? 'auto';
if (theme === 'auto') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)');
if (darkThemeMq.matches) {
document.body.setAttribute('class', `theme-dark`);
} else {
document.body.setAttribute('class', `theme-light`);
}
} else {
document.body.setAttribute('class', `theme-${theme}`);
}
</script>
</body>
</html>

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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([

View file

@ -1,6 +1,12 @@
<?php
return function ($context) {
if ($context->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 ?? ''));