From d7c130fca9ad04f912caa4c9b552229cac0b6b50 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:03:12 +0100 Subject: [PATCH] Serve SPA shell for deep frontend routes (#7145) # Description of Changes stops the /new urls crashing page on f5 --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../web/ReactRoutingController.java | 89 +++++++++++++++++++ .../web/ReactRoutingControllerTest.java | 89 +++++++++++++++++++ frontend/editor/scripts/lint/theme-lint.mjs | 6 +- 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 1e05ec17b2..7689d109fe 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -5,17 +5,26 @@ import java.io.InputStream; 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; @@ -32,6 +41,33 @@ public class ReactRoutingController { private static final Pattern BASE_HREF_PATTERN = Pattern.compile(""); + // 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 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; @@ -256,6 +292,59 @@ public class ReactRoutingController { 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 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 + "/"; diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java index 2df3fb5587..41909e3e0a 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java @@ -4,12 +4,24 @@ import static org.junit.jupiter.api.Assertions.*; 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; @@ -175,6 +187,83 @@ class ReactRoutingControllerTest { 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 router = routerOf(controller.spaDeepLinkFallbackMapping()); + + ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new"); + Optional> 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 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 routerOf(RouterFunctionMapping mapping) { + @SuppressWarnings("unchecked") + RouterFunction router = + (RouterFunction) 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 diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 0ae53a3ce9..42f6153119 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -314,6 +314,10 @@ 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). @@ -321,7 +325,7 @@ function check() { 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`, });