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,6 +1,12 @@
package run.halo.app.infra.exception.handlers;

import java.lang.reflect.Method;
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;
Expand All @@ -10,6 +16,7 @@
import org.springframework.http.HttpStatus;
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 +25,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 +40,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 +75,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 +105,61 @@ protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
.switchIfEmpty(Mono.defer(() -> noMatchExceptionHandler(error)));
}

protected Mono<ServerResponse> renderErrorView(ServerRequest request) {
Map<String, Object> error =
getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML));
int errorStatus = getHttpStatus(error);
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)
.filter(isAvailable -> isAvailable)
.flatMap(isAvailable -> responseBody.render(viewName, 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));
}
}