Skip to content

Commit

Permalink
Front controller fixes (#1098)
Browse files Browse the repository at this point in the history
* Don't cache resources which can vary per user because of the Accept-Language header

* Use property instead of EnvironmentAware, early return when Sentry not enabled

* Make FrontControllerResolver a component

* Use precompiled regexp and check requestPath is in supportedLanguages, also support paths for just the language prefix (with or without trailing slash)

* Add cache headers for static resources with hashed name and icons/media
  • Loading branch information
matthijsln authored Dec 24, 2024
1 parent f77969f commit 1cb9677
Show file tree
Hide file tree
Showing 4 changed files with 59 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.io.Resource;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import org.springframework.web.servlet.resource.ResourceResolver;
import org.springframework.web.servlet.resource.ResourceResolverChain;
Expand All @@ -26,21 +32,36 @@
* <br>
* When the user refreshes the page such routes are requested from the server.
*/
public class FrontControllerResolver implements ResourceResolver {
private final AcceptHeaderLocaleResolver localeResolver;
@Component
public class FrontControllerResolver implements ResourceResolver, InitializingBean {

private final ConfigurationRepository configurationRepository;
private final ApplicationRepository applicationRepository;
private final boolean staticOnly;

@Value("${spring.profiles.active:}")
private String activeProfile;

@Value("#{'${tailormap-api.supported-languages:en}'.split(',')}")
private List<String> supportedLanguages;

private AcceptHeaderLocaleResolver localeResolver;

private boolean staticOnly;

private Pattern localeBundlePrefixPattern = Pattern.compile("^[a-z]{2}/.*");

public FrontControllerResolver(
ConfigurationRepository configurationRepository,
ApplicationRepository applicationRepository,
List<String> supportedLanguages,
boolean staticOnly) {
// Inject these repositories lazily because in the static-only profile these are not needed
// but also not configured
@Lazy ConfigurationRepository configurationRepository,
@Lazy ApplicationRepository applicationRepository) {
this.configurationRepository = configurationRepository;
this.applicationRepository = applicationRepository;
this.staticOnly = staticOnly;
}

@Override
public void afterPropertiesSet() {
this.staticOnly = activeProfile.contains("static-only");

this.localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setSupportedLocales(supportedLanguages.stream().map(Locale::new).toList());
Expand All @@ -64,8 +85,13 @@ public Resource resolveResource(
}

// Check if the request path already starts with a locale prefix like en/ or nl/
if (requestPath.matches("^[a-z]{2}/.*")) {
return chain.resolveResource(request, requestPath.substring(0, 2) + "/index.html", locations);
String localePrefix = StringUtils.left(requestPath, 2);
if ((localeBundlePrefixPattern.matcher(requestPath).matches()
&& supportedLanguages.contains(localePrefix))
// When the request is just "GET /nl/" or "GET /nl" the requestPath is "nl" without a
// trailing slash
|| supportedLanguages.contains(requestPath)) {
return chain.resolveResource(request, localePrefix + "/index.html", locations);
}

// When the request path denotes an app, return the index.html for the default language
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,10 @@
package org.tailormap.api.configuration.base;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.lang3.StringUtils.isNotBlank;

import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
Expand All @@ -25,16 +19,9 @@
import org.springframework.web.servlet.resource.TransformedResource;

@Component
public class IndexHtmlTransformer implements ResourceTransformer, EnvironmentAware {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

private Environment environment;

@Override
public void setEnvironment(@NonNull Environment environment) {
this.environment = environment;
}
public class IndexHtmlTransformer implements ResourceTransformer {
@Value("${tailormap-api.sentry.dsn}")
private String sentryDsn;

@Override
@NonNull
Expand All @@ -43,22 +30,14 @@ public Resource transform(
@NonNull Resource resource,
@NonNull ResourceTransformerChain transformerChain)
throws IOException {
// Note that caching is not required because of cacheResources param to resourceChain() in
// WebMvcConfig

resource = transformerChain.transform(request, resource);

if (!"index.html".equals(resource.getFilename())) {
if (sentryDsn == null || !"index.html".equals(resource.getFilename())) {
return resource;
}

String html = IOUtils.toString(resource.getInputStream(), UTF_8);
String sentryDsn = environment.getProperty("VIEWER_SENTRY_DSN");
if (isNotBlank(sentryDsn)) {
logger.info(
"Sending Sentry DSN {} for index {}", sentryDsn, resource.getFile().getAbsolutePath());
html = html.replace("@SENTRY_DSN@", sentryDsn);
}
String html = resource.getContentAsString(UTF_8);
html = html.replace("@SENTRY_DSN@", sentryDsn);
return new TransformedResource(resource, html.getBytes(UTF_8));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@

package org.tailormap.api.configuration.base;

import java.util.List;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.format.FormatterRegistry;
import org.springframework.http.CacheControl;
import org.springframework.lang.NonNull;
Expand All @@ -18,35 +17,20 @@
import org.springframework.web.servlet.resource.EncodedResourceResolver;
import org.tailormap.api.configuration.CaseInsensitiveEnumConverter;
import org.tailormap.api.persistence.json.GeoServiceProtocol;
import org.tailormap.api.repository.ApplicationRepository;
import org.tailormap.api.repository.ConfigurationRepository;
import org.tailormap.api.scheduling.TaskType;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final FrontControllerResolver frontControllerResolver;
private final IndexHtmlTransformer indexHtmlTransformer;

@Value("${spring.web.resources.static-locations:file:/home/spring/static/}")
private String resourceLocations;

@Value("#{'${tailormap-api.supported-languages:en}'.split(',')}")
private List<String> supportedLanguages;

@Value("${spring.profiles.active:}")
private String activeProfile;

private final ConfigurationRepository configurationRepository;
private final ApplicationRepository applicationRepository;

public WebMvcConfig(
IndexHtmlTransformer indexHtmlTransformer,
// Inject these repositories lazily because in the static-only profile these are not needed
// but also not configured
@Lazy ConfigurationRepository configurationRepository,
@Lazy ApplicationRepository applicationRepository) {
FrontControllerResolver frontControllerResolver, IndexHtmlTransformer indexHtmlTransformer) {
this.frontControllerResolver = frontControllerResolver;
this.indexHtmlTransformer = indexHtmlTransformer;
this.configurationRepository = configurationRepository;
this.applicationRepository = applicationRepository;
}

@Override
Expand All @@ -55,20 +39,23 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
.addResourceHandler("/version.json")
.addResourceLocations(resourceLocations.split(",", -1)[0])
.setCacheControl(CacheControl.noStore());
registry
// Add cache headers for frontend bundle resources with hash in filename and fonts/images
.addResourceHandler("/*/*.js", "/*/*.css", "/*/*.map", "/*/media/**", "/*/icons/**")
.addResourceLocations(resourceLocations.split(",", -1)[0])
.setCacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).mustRevalidate())
.resourceChain(true)
.addResolver(new EncodedResourceResolver());
registry
.addResourceHandler("/**")
.addResourceLocations(resourceLocations.split(",", -1)[0])
// no-cache means the browser must revalidate index.html with a conditional HTTP request
// using If-Modified-Since. This is needed to always have the latest frontend loaded in the
// browser after deployment of a new release.
.setCacheControl(CacheControl.noCache())
.resourceChain(true)
.addResolver(
new FrontControllerResolver(
configurationRepository,
applicationRepository,
supportedLanguages,
activeProfile.contains("static-only")))
// Don't cache resources which can vary per user because of the Accept-Language header
.resourceChain(false)
.addResolver(frontControllerResolver)
.addResolver(new EncodedResourceResolver())
.addTransformer(indexHtmlTransformer);
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ tailormap-api.admin.base-path=/api/admin
# The first language is used as the default.
tailormap-api.supported-languages=en,nl,de

tailormap-api.sentry.dsn=${VIEWER_SENTRY_DSN:#{null}}

tailormap-api.security.admin.create-if-not-exists=true
tailormap-api.security.admin.username=tm-admin
# A hashed password can be specified in the ADMIN_HASHED_PASSWORD environment variable. If empty or not specified a
Expand Down

0 comments on commit 1cb9677

Please sign in to comment.