mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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>
This commit is contained in:
co-authored by
Reece Browne
parent
4d8a86ad28
commit
d7c130fca9
+89
@@ -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("<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;
|
||||
|
||||
@@ -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<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 + "/";
|
||||
|
||||
|
||||
+89
@@ -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<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
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user