Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
Dec 15, 2022
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>