feat: supports exception page template for theme-side (#2925)

#### What type of PR is this?
/kind feature
/area core
#### What this PR does / why we need it:
主题端支持异常模板页面

异常模板必须放在主题目录的 `templates/error` 目录下:
- 支持按照 response status 名称模板页面,例如 404.html ,当发生 404 错误时会使用 404.html
- 支持 4xx.html、5xx.html,例如当发生 403 错误时,如果存在 403.html 则使用此页面,否则使用 4xx.html
error 模板中具有 model 示例:
```json
{
    "error": {
        "type": "about:blank",
        "title": "Not Found",
        "status": 404,
        "detail": "Extension run.halo.app.core.extension.Plugin with name amet ut magn not found",
        "instance": "/apis/plugin.halo.run/v1alpha1/plugins/amet%20ut%20magn"
    }
}
```
#### Which issue(s) this PR fixes:

Fixes #2690

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
主题端支持异常模板页面
```
pull/2955/head
guqing 2022-12-15 10:44:10 +08:00 committed by GitHub
parent d5eb7b71cf
commit 686aece485
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 249 additions and 10 deletions

View File

@ -1,6 +1,13 @@
package run.halo.app.infra.exception.handlers;
import java.lang.reflect.Method;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.WebProperties;
@ -8,8 +15,10 @@ import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWeb
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.util.StringUtils;
import org.springframework.web.ErrorResponse;
import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver;
import org.springframework.web.reactive.BindingContext;
@ -18,7 +27,9 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.method.InvocableHandlerMethod;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.theme.ThemeResolver;
/**
* Global error web exception handler.
@ -31,11 +42,27 @@ import reactor.core.publisher.Mono;
*/
@Slf4j
public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler {
private static final MediaType TEXT_HTML_UTF8 =
new MediaType("text", "html", StandardCharsets.UTF_8);
private static final Map<HttpStatus.Series, String> SERIES_VIEWS;
private final ExceptionHandlingProblemDetailsHandler exceptionHandler =
new ExceptionHandlingProblemDetailsHandler();
private final ExceptionHandlerMethodResolver handlerMethodResolver =
new ExceptionHandlerMethodResolver(ExceptionHandlingProblemDetailsHandler.class);
private final ErrorProperties errorProperties;
private final ThemeResolver themeResolver;
static {
Map<HttpStatus.Series, String> views = new EnumMap<>(HttpStatus.Series.class);
views.put(HttpStatus.Series.CLIENT_ERROR, "4xx");
views.put(HttpStatus.Series.SERVER_ERROR, "5xx");
SERIES_VIEWS = Collections.unmodifiableMap(views);
}
/**
* Create a new {@code DefaultErrorWebExceptionHandler} instance.
*
@ -50,12 +77,13 @@ public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHand
ErrorProperties errorProperties,
ApplicationContext applicationContext) {
super(errorAttributes, resources, errorProperties, applicationContext);
this.errorProperties = errorProperties;
this.themeResolver = applicationContext.getBean(ThemeResolver.class);
}
@Override
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
Throwable error = getError(request);
log.error(error.getMessage(), error);
if (error instanceof ErrorResponse errorResponse) {
return ServerResponse.status(errorResponse.getStatusCode())
@ -79,6 +107,72 @@ public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHand
.switchIfEmpty(Mono.defer(() -> noMatchExceptionHandler(error)));
}
protected Mono<ServerResponse> renderErrorView(ServerRequest request) {
Map<String, Object> errorAttributes =
getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML));
int errorStatus = getHttpStatus(errorAttributes);
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(errorStatus),
(String) errorAttributes.get("message"));
problemDetail.setInstance(URI.create(request.path()));
Map<String, Object> error = Map.of("error", problemDetail);
ServerResponse.BodyBuilder responseBody =
ServerResponse.status(errorStatus).contentType(TEXT_HTML_UTF8);
return Flux.just(getData(errorStatus).toArray(new String[] {}))
.flatMap((viewName) -> renderErrorViewBy(request, viewName, responseBody, error))
.switchIfEmpty(this.errorProperties.getWhitelabel().isEnabled()
? renderDefaultErrorView(responseBody, error) : Mono.error(getError(request)))
.next();
}
protected void logError(ServerRequest request, ServerResponse response, Throwable throwable) {
if (log.isDebugEnabled()) {
log.debug(request.exchange().getLogPrefix() + formatError(throwable, request),
throwable);
}
if (HttpStatus.resolve(response.statusCode().value()) != null
&& response.statusCode().equals(HttpStatus.INTERNAL_SERVER_ERROR)) {
log.error("{} 500 Server Error for {}",
request.exchange().getLogPrefix(), formatRequest(request), throwable);
}
}
private String formatRequest(ServerRequest request) {
String rawQuery = request.uri().getRawQuery();
String query = StringUtils.hasText(rawQuery) ? "?" + rawQuery : "";
return "HTTP " + request.method() + " \"" + request.path() + query + "\"";
}
private String formatError(Throwable ex, ServerRequest request) {
String reason = ex.getClass().getSimpleName() + ": " + ex.getMessage();
return "Resolved [" + reason + "] for HTTP " + request.method() + " " + request.path();
}
private Mono<ServerResponse> renderErrorViewBy(ServerRequest request, String viewName,
ServerResponse.BodyBuilder responseBody,
Map<String, Object> error) {
return themeResolver.isTemplateAvailable(request.exchange().getRequest(), viewName)
.flatMap(isAvailable -> {
if (isAvailable) {
return responseBody.render(viewName, error);
}
return super.renderErrorView(viewName, responseBody, error);
});
}
private List<String> getData(int errorStatus) {
List<String> data = new ArrayList<>();
data.add("error/" + errorStatus);
HttpStatus.Series series = HttpStatus.Series.resolve(errorStatus);
if (series != null) {
data.add("error/" + SERIES_VIEWS.get(series));
}
data.add("error/error");
return data;
}
Mono<ServerResponse> noMatchExceptionHandler(Throwable error) {
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)

View File

@ -1,6 +1,9 @@
package run.halo.app.theme;
import java.nio.file.Files;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@ -14,17 +17,14 @@ import run.halo.app.infra.utils.FilePathUtils;
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class ThemeResolver {
private static final String THEME_WORK_DIR = "themes";
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final HaloProperties haloProperties;
public ThemeResolver(SystemConfigurableEnvironmentFetcher environmentFetcher,
HaloProperties haloProperties) {
this.environmentFetcher = environmentFetcher;
this.haloProperties = haloProperties;
}
private final ThymeleafProperties thymeleafProperties;
public Mono<ThemeContext> getTheme(ServerHttpRequest request) {
return environmentFetcher.fetch(Theme.GROUP, Theme.class)
@ -37,10 +37,7 @@ public class ThemeResolver {
if (StringUtils.isBlank(themeName)) {
themeName = activatedTheme;
}
boolean active = false;
if (StringUtils.equals(activatedTheme, themeName)) {
active = true;
}
boolean active = StringUtils.equals(activatedTheme, themeName);
var path = FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
THEME_WORK_DIR, themeName);
return builder.name(themeName)
@ -50,4 +47,22 @@ public class ThemeResolver {
});
}
/**
* Check whether the template file exists.
*
* @param viewName view name must not be blank
* @return if exists return true, otherwise return false
*/
public Mono<Boolean> isTemplateAvailable(ServerHttpRequest request, String viewName) {
return getTheme(request)
.map(themeContext -> {
String prefix = themeContext.getPath() + "/templates/";
String viewNameToUse = viewName;
if (!viewNameToUse.endsWith(thymeleafProperties.getSuffix())) {
viewNameToUse = viewNameToUse + thymeleafProperties.getSuffix();
}
return Files.exists(FilePathUtils.combinePath(prefix, viewNameToUse));
})
.onErrorResume(e -> Mono.just(false));
}
}

View File

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="zh" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title th:text="${error.status} + ' | ' + ${#strings.defaultString(error.title, 'Internal server error')}"></title>
<style>
body {
padding: 30px 20px;
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell",
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
color: #727272;
line-height: 1.6;
}
.container {
max-width: 500px;
margin: 0 auto;
}
h1 {
margin: 0;
font-size: 60px;
line-height: 1;
color: #252427;
font-weight: 700;
display: inline-block;
}
h2 {
margin: 100px 0 0;
font-weight: 600;
letter-spacing: 0.1em;
color: #A299AC;
text-transform: uppercase;
}
p {
font-size: 16px;
margin: 1em 0;
}
@media screen and (min-width: 768px) {
body {
padding: 50px;
}
}
@media screen and (max-width: 480px) {
h1 {
font-size: 48px;
}
}
.title {
position: relative;
}
.title::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: #000;
transform-origin: bottom right;
transform: scaleX(0);
transition: transform 0.5s ease;
}
.title:hover::before {
transform-origin: bottom left;
transform: scaleX(1);
}
.back-home button {
z-index: 1;
position: relative;
font-size: inherit;
font-family: inherit;
color: white;
padding: 0.5em 1em;
outline: none;
border: none;
background-color: hsl(0, 0%, 0%);
overflow: hidden;
transition: color 0.4s ease-in-out;
}
.back-home button::before {
content: '';
z-index: -1;
position: absolute;
top: 100%;
left: 100%;
width: 1em;
height: 1em;
border-radius: 50%;
background-color: #fff;
transform-origin: center;
transform: translate3d(-50%, -50%, 0) scale3d(0, 0, 0);
transition: transform 0.45s ease-in-out;
}
.back-home button:hover {
cursor: pointer;
color: #000;
}
.back-home button:hover::before {
transform: translate3d(-50%, -50%, 0) scale3d(15, 15, 15);
}
</style>
</head>
<body>
<div class="container">
<h2 th:text="${error.status}"></h2>
<h1 class="title" th:text="${#strings.defaultString(error.title, 'Internal server error')}"></h1>
<p th:text="${#strings.defaultString(error.detail, '未知错误!可能存在的原因:未正确设置主题或主题文件缺失。')}"></p>
<div class="back-home">
<button th:onclick="window.location.href='[(${site.url})]'"
th:text="首页">
</button>
</div>
</div>
</body>
</html>