Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.JavaScriptUtils;

Expand All @@ -32,6 +41,33 @@ public class ReactRoutingController {
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");

// First path segments owned by the backend or static assets, never SPA routes.
// Mirrors the exclusion regexes on forwardRootPaths/forwardNestedPaths below.
private static final Set<String> NON_SPA_FIRST_SEGMENTS =
Set.of(
"api",
"static",
"pipeline",
"pdfjs",
"pdfjs-legacy",
"pdfium",
"vendor",
"fonts",
"images",
"css",
"js",
"assets",
"locales",
"modern-logo",
"classic-logo",
"Login",
"og_images",
"samples");

// After the annotated controllers (order 0), before the resource chain
// (LOWEST_PRECEDENCE - 1).
private static final int SPA_FALLBACK_ORDER = Ordered.LOWEST_PRECEDENCE - 2;

@Value("${server.servlet.context-path:/}")
private String contextPath;

Expand Down Expand Up @@ -256,6 +292,59 @@ public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
return serveIndexHtml(request);
}

// The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot
// span '/'), so deep SPA links like /processor/pipelines/new 404d on direct navigation.
//
// Registered as its own mapping rather than exposed as a bare RouterFunction @Bean:
// Spring's own RouterFunctionMapping is ordered -1, ahead of the annotated controllers at
// order 0, so a plain bean would shadow every dot-free backend route the denylist below
// does not name (/v1/api-docs, /error, /actuator, ...). LOWEST_PRECEDENCE - 2 puts it after
// the controllers and before the resource chain (LOWEST_PRECEDENCE - 1), which is the only
// position where a catch-all fallback is safe.
@Bean
public RouterFunctionMapping spaDeepLinkFallbackMapping() {
RouterFunction<ServerResponse> fallback =
RouterFunctions.route(
request -> {
HttpServletRequest servletRequest = request.servletRequest();
return "GET".equals(servletRequest.getMethod())
&& isSpaFallbackRoute(
stripContextPath(
servletRequest.getContextPath(),
servletRequest.getRequestURI()));
},
request ->
ServerResponse.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(serveIndexHtml(request.servletRequest()).getBody()));
RouterFunctionMapping mapping = new RouterFunctionMapping(fallback);
mapping.setOrder(SPA_FALLBACK_ORDER);
mapping.setMessageConverters(
List.of(new StringHttpMessageConverter(StandardCharsets.UTF_8)));
return mapping;
}

// Dot-free paths only, so requests for real files still fall through to the resource
// handlers. This is a denylist, so it is only safe because the mapping above runs after
// the annotated controllers - see spaDeepLinkFallbackMapping.
static boolean isSpaFallbackRoute(String path) {
if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) {
return false;
}
String[] segments = (path.startsWith("/") ? path.substring(1) : path).split("/");
return segments.length > 0
&& !segments[0].isEmpty()
&& !NON_SPA_FIRST_SEGMENTS.contains(segments[0]);
}

private static String stripContextPath(String contextPath, String uri) {
if (contextPath != null && !contextPath.isBlank() && uri.startsWith(contextPath)) {
return uri.substring(contextPath.length());
}
return uri;
}

private String buildFallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@
import static org.mockito.Mockito.mock;

import java.lang.reflect.Field;
import java.util.List;
import java.util.Optional;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.servlet.function.EntityResponse;
import org.springframework.web.servlet.function.HandlerFunction;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.ServletRequestPathUtils;

import jakarta.servlet.http.HttpServletRequest;

Expand Down Expand Up @@ -175,6 +187,83 @@ void forwardNestedPaths_servesIndexHtml() throws Exception {
assertNotNull(response.getBody());
}

// --- deep-link SPA fallback (router function) ---

@Test
void isSpaFallbackRoute_acceptsDeepSpaPaths() {
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/123/runs/456"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/workflow/sign/some-token"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new/"));
// "pipelines" must not be swallowed by the "pipeline" exclusion
assertTrue(ReactRoutingController.isSpaFallbackRoute("/pipelines"));
}

@Test
void isSpaFallbackRoute_rejectsBackendStaticAndFilePaths() {
assertFalse(ReactRoutingController.isSpaFallbackRoute("/api/v1/some/endpoint"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline/anything"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/assets/deep/path"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/file.js"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/branding/sub/logo.png"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/"));
assertFalse(ReactRoutingController.isSpaFallbackRoute(""));
assertFalse(ReactRoutingController.isSpaFallbackRoute(null));
}

@Test
void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());

ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new");
Optional<HandlerFunction<ServerResponse>> handler = router.route(deepRequest);
assertTrue(handler.isPresent());

ServerResponse response = handler.get().handle(deepRequest);
assertEquals(HttpStatus.OK, response.statusCode());
assertInstanceOf(EntityResponse.class, response);
Object body = ((EntityResponse<?>) response).entity();
assertTrue(body.toString().contains("Stirling PDF"));
}

@Test
void spaDeepLinkFallback_ignoresApiFilesAndNonGet() {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());

assertTrue(router.route(serverRequest("GET", "/api/v1/policies/run")).isEmpty());
assertTrue(router.route(serverRequest("GET", "/branding/sub/logo.png")).isEmpty());
assertTrue(router.route(serverRequest("POST", "/processor/pipelines/new")).isEmpty());
}

@Test
void spaDeepLinkFallback_runsAfterControllersAndBeforeResources() {
controller.init();
int order = controller.spaDeepLinkFallbackMapping().getOrder();

// A catch-all denylist is only safe below every annotated controller; Spring's own
// RouterFunctionMapping sits at -1, which would shadow /v1/api-docs, /error and friends.
assertTrue(order > 0, "SPA fallback must run after annotated controllers");
assertTrue(
order < Ordered.LOWEST_PRECEDENCE - 1,
"SPA fallback must run before the static-resource chain");
}

private static RouterFunction<ServerResponse> routerOf(RouterFunctionMapping mapping) {
@SuppressWarnings("unchecked")
RouterFunction<ServerResponse> router =
(RouterFunction<ServerResponse>) mapping.getRouterFunction();
return router;
}

private static ServerRequest serverRequest(String method, String uri) {
MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, uri);
ServletRequestPathUtils.parseAndCache(servletRequest);
return ServerRequest.create(servletRequest, List.of(new StringHttpMessageConverter()));
}

// --- context path handling ---

@Test
Expand Down
6 changes: 5 additions & 1 deletion frontend/editor/scripts/lint/theme-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,18 @@ function check() {
const violations = [];
const primitiveValues = new Map();
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
// path.relative emits backslashes on Windows; normalize so the PRIMITIVES
// comparison below matches and printed paths stay POSIX-style.
const posixRel = (name) =>
relative(process.cwd(), join(THEME, name)).replaceAll("\\", "/");

// Fail if a theme .css exists that isn't registered above (readdir is only
// compared here — never used to build a path passed to readFileSync).
const known = new Set(THEME_FILES);
for (const name of readdirSync(THEME)) {
if (name.endsWith(".css") && !known.has(name)) {
violations.push({
file: relative(process.cwd(), join(THEME, name)),
file: posixRel(name),
line: 1,
msg: `unregistered theme CSS — add "${name}" to THEME_FILES in theme-lint.mjs`,
});
Expand Down
Loading