FrontControllerResolver.java

/*
 * Copyright (C) 2024 B3Partners B.V.
 *
 * SPDX-License-Identifier: MIT
 */

package org.tailormap.api.configuration.base;

import jakarta.servlet.http.HttpServletRequest;
import java.io.File;
import java.lang.invoke.MethodHandles;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
import org.springframework.web.util.UriUtils;
import org.tailormap.api.persistence.Application;
import org.tailormap.api.persistence.Configuration;
import org.tailormap.api.repository.ApplicationRepository;
import org.tailormap.api.repository.ConfigurationRepository;

/**
 * Resolver which returns index.html for requests to paths created by the Angular routing module. <br>
 * When the user refreshes the page such routes are requested from the server.
 */
@Component
public class FrontControllerResolver implements ResourceResolver, InitializingBean {
  private static final Logger logger =
      LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  private final ConfigurationRepository configurationRepository;
  private final ApplicationRepository applicationRepository;

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

  @Value("#{'${spring.web.resources.static-locations:}'.split(',')}")
  private List<String> staticResourceLocations;

  @Value("${tailormap-api.default-language:en}")
  private String defaultLanguage;

  private List<String> supportedLanguages = Collections.emptyList();

  private AcceptHeaderLocaleResolver localeResolver;

  private boolean staticOnly;

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

  public FrontControllerResolver(
      // 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;
  }

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

    this.localeResolver = new AcceptHeaderLocaleResolver();

    try {
      for (String resourceLocation : staticResourceLocations) {
        Path resourcePath = resourceLocation.startsWith("file:")
            ? Path.of(Strings.CS.removeStart(resourceLocation, "file:"))
            : null;
        // Check whether the resource path has the Tailormap frontend which always has a
        // version.json
        if (resourcePath != null
            && resourcePath.resolve("version.json").toFile().exists()) {
          // Only check for locale bundle directories when the frontend is built with localization.
          // When index.html exists this is a non-localized build -- leave supportedLanguages empty
          if (resourcePath.resolve("index.html").toFile().exists()) {
            break;
          }

          File[] languageBundleDirs =
              resourcePath.toFile().listFiles((file, name) -> name.matches("^[a-z]{2}$"));
          if (languageBundleDirs != null && languageBundleDirs.length > 0) {
            supportedLanguages = Arrays.stream(languageBundleDirs)
                .map(File::getName)
                .toList();
            logger.info("Detected frontend bundles for languages: {}", supportedLanguages);
          }
          break;
        }
      }
    } catch (Exception e) {
      logger.error("Error while trying to determine frontend languages bundles", e);
    }

    if (!supportedLanguages.isEmpty()) {
      localeResolver.setSupportedLocales(
          supportedLanguages.stream().map(Locale::of).toList());
      Locale defaultLocale = supportedLanguages.contains(defaultLanguage)
          ? Locale.of(defaultLanguage)
          : localeResolver.getSupportedLocales().getFirst();
      localeResolver.setDefaultLocale(defaultLocale);
      logger.info("Default language set to: {}", defaultLocale.toLanguageTag());
    }
  }

  @Override
  public Resource resolveResource(
      HttpServletRequest request,
      @NonNull String requestPath,
      @NonNull List<? extends Resource> locations,
      ResourceResolverChain chain) {
    // Front controller logic: when routes used by the frontend are directly requested, return the
    // index.html instead of a 404.
    // Paths in @RequestMapping have higher priority than this resolver

    // When the resource exists (such as HTML, CSS, JS, etc.), return it
    Resource resource = chain.resolveResource(request, requestPath, locations);
    if (resource != null) {
      return resource;
    }

    // Check if the request path already starts with a locale prefix like en/ or nl/
    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
    // configured for the app

    if (!staticOnly) {
      Application app = null;
      if ("index.html".equals(requestPath) || requestPath.matches("^app/?")) {
        String defaultAppName = configurationRepository.get(Configuration.DEFAULT_APP);
        app = applicationRepository.findByName(defaultAppName);
      } else if (requestPath.startsWith("app/")) {
        String[] parts = requestPath.split("/", -1);
        if (parts.length > 1) {
          String appName = UriUtils.decode(parts[1], StandardCharsets.UTF_8);
          app = applicationRepository.findByName(appName);
        }
      }

      if (app != null && app.getSettings().getI18nSettings() != null) {
        String appLanguage = app.getSettings().getI18nSettings().getDefaultLanguage();
        if (appLanguage != null) {
          resource = chain.resolveResource(request, appLanguage + "/index.html", locations);
          if (resource != null) {
            return resource;
          }
        }
      }
    }

    // Otherwise use the LocaleResolver to return the index.html for the language of the
    // Accept-Language header
    Locale locale = localeResolver.resolveLocale(request);
    resource = chain.resolveResource(request, locale.toLanguageTag() + "/index.html", locations);

    // When frontend is built without localization, return the index.html in the root
    if (resource == null) {
      resource = chain.resolveResource(request, "/index.html", locations);
    }
    return resource;
  }

  @Override
  public String resolveUrlPath(
      @NonNull String resourcePath, @NonNull List<? extends Resource> locations, ResourceResolverChain chain) {
    return chain.resolveUrlPath(resourcePath, locations);
  }
}