Skip to content

Commit

Permalink
feat: supports exception page template for theme-side (#2925)
Browse files Browse the repository at this point in the history
#### 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
主题端支持异常模板页面
```
  • Loading branch information
guqing authored Dec 15, 2022
1 parent d5eb7b7 commit 686aece
Show file tree
Hide file tree
Showing 3 changed files with 249 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
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;
import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler;
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;
Expand All @@ -18,7 +27,9 @@
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.
Expand All @@ -31,11 +42,27 @@
*/
@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.
*
Expand All @@ -50,12 +77,13 @@ public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes,
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())
Expand All @@ -79,6 +107,72 @@ protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
.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)
Expand Down
33 changes: 24 additions & 9 deletions src/main/java/run/halo/app/theme/ThemeResolver.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,17 +17,14 @@
* @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)
Expand All @@ -37,10 +37,7 @@ public Mono<ThemeContext> getTheme(ServerHttpRequest request) {
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)
Expand All @@ -50,4 +47,22 @@ public Mono<ThemeContext> getTheme(ServerHttpRequest request) {
});
}

/**
* 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));
}
}
130 changes: 130 additions & 0 deletions src/main/resources/templates/error/error.html
Original file line number Diff line number Diff line change
@@ -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>

0 comments on commit 686aece

Please sign in to comment.