Search code examples
spring-mvctomcatfreemarkerhttp-errorsitemesh

Spring and SiteMesh Error Page is not decorated (skips main filters)


I've been struggling with a rather absurd problem for a few days now: The project I'm on is using Spring MVC with FreeMarker for it's templating.

This is running atop a Tomcat container (testing locally using Cargo).

The issue I'm working has the brief of implementing uniform behaviour in a standardised error page but covering covering the various types of errors that may be encountered. (Exceptions bubbling up from back-end services, inadequate permissions, http errors, etc)

So far, the results are as follows (Graphic included): Examples of 3 Successful error displays(basic request and intercepted exceptions, against failing example (HTTP errors 4xx, 5xx, etc)

  • Fig A: Normal navigation to page - renders as expected.
  • Fig B & Fig C: Service and Permission Exceptions caught by ControllerAdvice.java - likewise, no issues.
  • Fig D: Any HTTP Error (yes, even 418 if you trigger that response) - Inner freemarker template is correctly retrieved and populated with bindings but decorations applied by filters fail to trigger.

Currently we're using Spring to configure the servlet handling so the web.xml is beautifully sparse:

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
         http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
    version="3.1">

<!--
 This application uses the config of the mapping by Spring MVC
 This is why you will not see servlet declarations here

 The web app is defined in 
 - butler.SpringWebInit
 - butler.SpringWebConfig
 -->

    <context-param>
        <description>Escape HTML form data by default when using Spring tags</description>
        <param-name>defaultHtmlEscape</param-name>
        <param-value>true</param-value>
    </context-param>

<!-- Disabling welcome list file for Tomcat, handling it in Spring MVC -->
    <welcome-file-list>
        <welcome-file/>
    </welcome-file-list>

<!-- Generic Error redirection, allows for handling in Spring MVC -->
    <error-page>
        <location>/http-error</location>
        <!-- Was originally just "/error" it was changed for internal forwarding/proxying/redirection attempts -->
    </error-page>
</web-app>

The Configuration is handled by SpringWebInit.java to which I have not made any modifications:

SpringWebInit.java

/**
 * Automatically loaded by class org.springframework.web.SpringServletContainerInitializer
 * 
 * @see http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle/#mvc-container-config
 * 
 *      According to {@link AbstractSecurityWebApplicationInitializer}, this class should be
 *      annotated with a Order so that it is loaded before {@link SpringSecurityInit}
 */
@Order(0)
public class SpringWebInit extends AbstractAnnotationConfigDispatcherServletInitializer implements InitializingBean {
  private final Logger LOG = LoggerFactory.getLogger(getClass());

  @Override
  public void afterPropertiesSet() throws Exception {
    LOG.info("DispatcherServlet loaded");
  }

  @Override
  protected Class<?>[] getServletConfigClasses() {
    return null; // returning null, getRootConfigClasses() will handle this as well
  }

  @Override
  protected String[] getServletMappings() {
    return new String[] {"/**"}; // Spring MVC should handle everything
  }

  @Override
  protected Class<?>[] getRootConfigClasses() {
    return new Class[] {SpringWebConfig.class, SpringSecurityConfig.class};
  }

  @Override
  protected Filter[] getServletFilters() {
    CharacterEncodingFilter characterEncodingFilter =
        new CharacterEncodingFilter(StandardCharsets.UTF_8.name(), true);
    return new Filter[] {characterEncodingFilter, new SiteMeshFilter()};
  }

}

Which in turn loads The various config for Freemarker and Sitemesh:

SpringWebConfig.java

@EnableWebMvc
@Configuration
@PropertySource("classpath:/butler-init.properties")
@ComponentScan({"butler"})
class SpringWebConfig extends WebMvcConfigurerAdapter implements InitializingBean {
  private final Logger LOG = LoggerFactory.getLogger(getClass());

  @Autowired
  LoggedInUserService loggedInUserService;

  @Override
  public void afterPropertiesSet() throws Exception {
    LOG.info("Web Mvc Configurer loaded");
  }

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(userHeaderInterceptor());
  }

  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/static/**").addResourceLocations("/static/").setCacheControl(
        CacheControl.maxAge(30, TimeUnit.MINUTES).noTransform().cachePublic().mustRevalidate());
  }

  @Bean
  FreeMarkerViewResolver viewResolver() throws TemplateException {
    FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
    resolver.setCache(/*true*/false); // Set to false for debugging
    resolver.setPrefix("");
    resolver.setSuffix(".ftlh");
    resolver.setRequestContextAttribute("rContext");
    resolver.setContentType("text/html;charset=UTF-8");

    DefaultObjectWrapper wrapper =
        new DefaultObjectWrapperBuilder(freemarker.template.Configuration.getVersion()).build();
    Map<String, Object> attrs = new HashMap<>();
    attrs.put("loggedInUserService", wrapper.wrap(loggedInUserService));
    resolver.setAttributesMap(attrs);

    return resolver;
  }

  @Bean
  FreeMarkerConfigurer freeMarkerConfig() {
    Properties freeMarkerVariables = new Properties();
    // http://freemarker.org/docs/pgui_config_incompatible_improvements.html
    // http://freemarker.org/docs/pgui_config_outputformatsautoesc.html
    freeMarkerVariables.put(freemarker.template.Configuration.INCOMPATIBLE_IMPROVEMENTS_KEY,
        freemarker.template.Configuration.getVersion().toString());

    FreeMarkerConfigurer freeMarkerConfigurer = new FreeMarkerConfigurer();
    freeMarkerConfigurer.setDefaultEncoding("UTF-8");
    freeMarkerConfigurer.setTemplateLoaderPath("/WEB-INF/mvc/view/ftl/");
    freeMarkerConfigurer.setFreemarkerSettings(freeMarkerVariables);
    return freeMarkerConfigurer;
  }

  @Bean
  UserHeaderInterceptor userHeaderInterceptor() {
    return new UserHeaderInterceptor();
  }

  @Bean
  static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() {
    return new PropertySourcesPlaceholderConfigurer();
  }
}

SiteMeshFilter.java

public class SiteMeshFilter extends ConfigurableSiteMeshFilter {

  @Override
  protected void applyCustomConfiguration(SiteMeshFilterBuilder builder) {    

    // Don't use decorator REST api pages
    builder.addExcludedPath("/api/*");

    builder.addDecoratorPath("/*", Views.DECORATOR_HEADER_FOOTER);
    builder.setIncludeErrorPages(true);
  }
}

Finally, onto the meat of the problem, the error handling is being handled via a combination of DefaultControllerAdvice.java, which provides the rules for intercepting exceptions and ErrorController.java itself, which handles the mappings and eventually, the message handling (displaying information about the error, adapting according to the type of error, etc)

DefaultControllerAdvice.java

@ControllerAdvice(annotations = Controller.class)
class DefaultControllerAdvice {

  private static String EXCEPTION = "butlerexception";

  @ExceptionHandler(ServiceException.class)
  public String exceptionHandler(ServiceException se, Model model) {
    model.addAttribute(EXCEPTION, se.getMessage());
    return Views.ERROR;
  }

  @ExceptionHandler(PermissionException.class)
  public String exceptionHandler(PermissionException pe, Model model) {
    model.addAttribute(EXCEPTION, "Incorrect Permissions");
    return Views.ERROR;
  }

  /*@ResponseStatus(HttpStatus.NOT_FOUND)
  @ExceptionHandler(IOException.class)
  public String exceptionHandler(Model model) { // Trying another way of intercepting 404 errors
    model.addAttribute(EXCEPTION, "HTTP Error: 404");
    return Views.ERROR;
  }*/
}

ErrorController.java

@Controller
class ErrorController extends AbstractController {

  @Autowired
  private LoggedInUserService loggedInUserService;

  @RequestMapping(path="error",method = {GET,POST}) // Normal Error Controller, Returns fully decorated page without issue for Exceptions and normal requests.
  public String error(RedirectAttributes redirectAttributes, HttpServletResponse response,Model model) {
    //if (redirectAttributes.containsAttribute("errorCode")) { // Trying to invisibly use redirection
    //  Map<String, ?> redirAttribs = redirectAttributes.getFlashAttributes();
    //  model.addAttribute("butlerexception", "HTTP Error: "+redirAttribs.get("errorCode"));
    //} else {
    model.addAttribute("butlerexception", "Error");
    //}
    return ERROR;
  }

  @RequestMapping("/http-error") // Created to test HTTP requests being proxied via ServiceExceptions, Redirections, etc...
  public String httpError(/*RedirectAttributes redirectAttributes,*/ HttpServletResponse response, HttpServletRequest request, Model model){
    model.addAttribute("butlerexception", "HTTP Error: " + response.getStatus());

    //throw new ServiceException("HTTP Error: " + response.getStatus()); // Trying to piggyback off Exception handling

    //redirectAttributes.addFlashAttribute("errorCode", response.getStatus()); // Trying to invisibly use redirection
    //redirectAttributes.addFlashAttribute("originalURL",request.getRequestURL());
    return /*"redirect:"+*/ERROR;
  }
}

So Far, I have tried:

  • Throwing exceptions to piggy-back off the working ControllerAdvice rules. - Result was undecorated.
  • Adding in Rules for response codes, IONotFound nad NoHandlerFound exceptions - Result was undecorated.
  • Redirecting to the error page - Result was decorated correctly, but URL and response codes were incorrect, attempting to mask the URL with the original request URL resulted in the correct URL and code, but the same lack of decoration as before.

Additionally, from the debugging logs, I can see that the filters from Spring Security are triggered normally but the ones involved with decorating the site (for both logged in and anonymous requests) fail to trigger for HTTP errors only.

One of the limiting factors currently is that I cannot gut the system and define it all in the web.xml (as many of the solutions here and in the Spring documentation seem to call for) without causing excessive disruption to development at this stage. (nor do I have the authority to effect such a change (Junior rank))

For Convenience sake, a few of the solutions I've tried so far:

At this point I'm really not sure what else to try, what on earth am I missing here?

Edit: it turned out to be a bug in SiteMesh to do with the triggering of .setContentType(...) that was solved via setting the contentType again after sitemesh in order to trigger decoration: Bug report with description and solution


Solution

  • This turned out to a two-part issue, firstly SiteMesh3's handling of error pages means that it believes it has processed all the filters even when an error causes decorators to be skipped. (expanded upon in this issue on github)

    The second part was that SiteMesh3 appears to only buffer pages for decoration when SpringMVC calls .setContentType(...).

    This was tripping up since Spring will only trigger this on elements with undefined content type whereas errors have already had their content type defined before they even reach Spring. (expanded upon by my lead in this issue)

    My lead managed to solve this by adding a filter after SiteMesh that triggered .setContentType(...) and forced SiteMesh to buffer the page for decoration.

    It's a little heavy, since it means that the content type is set twice per request, but it works.


    Edit: Originally had a note here asking not to upvote to avoid receiving rep for a solution my lead found, but found a blog post explaining that self-answers don't earn rep - huzzah!