mirror of https://github.com/halo-dev/halo
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
parent
d5eb7b71cf
commit
686aece485
|
@ -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)
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue