Skip to content

Commit 3dee1c0

Browse files
committed
order spa fallback
1 parent c1d7e29 commit 3dee1c0

2 files changed

Lines changed: 63 additions & 19 deletions

File tree

app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,26 @@
55
import java.nio.charset.StandardCharsets;
66
import java.nio.file.Files;
77
import java.nio.file.Path;
8+
import java.util.List;
89
import java.util.Set;
910
import java.util.regex.Pattern;
1011

1112
import org.springframework.beans.factory.annotation.Value;
1213
import org.springframework.context.annotation.Bean;
14+
import org.springframework.core.Ordered;
1315
import org.springframework.core.io.ClassPathResource;
1416
import org.springframework.core.io.FileSystemResource;
1517
import org.springframework.core.io.Resource;
1618
import org.springframework.http.CacheControl;
1719
import org.springframework.http.MediaType;
1820
import org.springframework.http.ResponseEntity;
21+
import org.springframework.http.converter.StringHttpMessageConverter;
1922
import org.springframework.stereotype.Controller;
2023
import org.springframework.web.bind.annotation.GetMapping;
2124
import org.springframework.web.servlet.function.RouterFunction;
2225
import org.springframework.web.servlet.function.RouterFunctions;
2326
import org.springframework.web.servlet.function.ServerResponse;
27+
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
2428
import org.springframework.web.util.HtmlUtils;
2529
import org.springframework.web.util.JavaScriptUtils;
2630

@@ -60,6 +64,10 @@ public class ReactRoutingController {
6064
"og_images",
6165
"samples");
6266

67+
// After the annotated controllers (order 0), before the resource chain
68+
// (LOWEST_PRECEDENCE - 1).
69+
private static final int SPA_FALLBACK_ORDER = Ordered.LOWEST_PRECEDENCE - 2;
70+
6371
@Value("${server.servlet.context-path:/}")
6472
private String contextPath;
6573

@@ -286,26 +294,40 @@ public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
286294

287295
// The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot
288296
// span '/'), so deep SPA links like /processor/pipelines/new 404d on direct navigation.
297+
//
298+
// Registered as its own mapping rather than exposed as a bare RouterFunction @Bean:
299+
// Spring's own RouterFunctionMapping is ordered -1, ahead of the annotated controllers at
300+
// order 0, so a plain bean would shadow every dot-free backend route the denylist below
301+
// does not name (/v1/api-docs, /error, /actuator, ...). LOWEST_PRECEDENCE - 2 puts it after
302+
// the controllers and before the resource chain (LOWEST_PRECEDENCE - 1), which is the only
303+
// position where a catch-all fallback is safe.
289304
@Bean
290-
public RouterFunction<ServerResponse> spaDeepLinkFallback() {
291-
return RouterFunctions.route(
292-
request -> {
293-
HttpServletRequest servletRequest = request.servletRequest();
294-
return "GET".equals(servletRequest.getMethod())
295-
&& isSpaFallbackRoute(
296-
stripContextPath(
297-
servletRequest.getContextPath(),
298-
servletRequest.getRequestURI()));
299-
},
300-
request ->
301-
ServerResponse.ok()
302-
.cacheControl(CacheControl.noCache().mustRevalidate())
303-
.contentType(MediaType.TEXT_HTML)
304-
.body(serveIndexHtml(request.servletRequest()).getBody()));
305+
public RouterFunctionMapping spaDeepLinkFallbackMapping() {
306+
RouterFunction<ServerResponse> fallback =
307+
RouterFunctions.route(
308+
request -> {
309+
HttpServletRequest servletRequest = request.servletRequest();
310+
return "GET".equals(servletRequest.getMethod())
311+
&& isSpaFallbackRoute(
312+
stripContextPath(
313+
servletRequest.getContextPath(),
314+
servletRequest.getRequestURI()));
315+
},
316+
request ->
317+
ServerResponse.ok()
318+
.cacheControl(CacheControl.noCache().mustRevalidate())
319+
.contentType(MediaType.TEXT_HTML)
320+
.body(serveIndexHtml(request.servletRequest()).getBody()));
321+
RouterFunctionMapping mapping = new RouterFunctionMapping(fallback);
322+
mapping.setOrder(SPA_FALLBACK_ORDER);
323+
mapping.setMessageConverters(
324+
List.of(new StringHttpMessageConverter(StandardCharsets.UTF_8)));
325+
return mapping;
305326
}
306327

307-
// Runs after annotated controllers but before the static-resource chain. Dot-free paths
308-
// only, so requests for real files still fall through to the resource handlers.
328+
// Dot-free paths only, so requests for real files still fall through to the resource
329+
// handlers. This is a denylist, so it is only safe because the mapping above runs after
330+
// the annotated controllers - see spaDeepLinkFallbackMapping.
309331
static boolean isSpaFallbackRoute(String path) {
310332
if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) {
311333
return false;

app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import org.junit.jupiter.api.BeforeEach;
1111
import org.junit.jupiter.api.Test;
12+
import org.springframework.core.Ordered;
1213
import org.springframework.http.HttpStatus;
1314
import org.springframework.http.MediaType;
1415
import org.springframework.http.ResponseEntity;
@@ -19,6 +20,7 @@
1920
import org.springframework.web.servlet.function.RouterFunction;
2021
import org.springframework.web.servlet.function.ServerRequest;
2122
import org.springframework.web.servlet.function.ServerResponse;
23+
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
2224
import org.springframework.web.util.ServletRequestPathUtils;
2325

2426
import jakarta.servlet.http.HttpServletRequest;
@@ -213,7 +215,7 @@ void isSpaFallbackRoute_rejectsBackendStaticAndFilePaths() {
213215
@Test
214216
void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception {
215217
controller.init();
216-
RouterFunction<ServerResponse> router = controller.spaDeepLinkFallback();
218+
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
217219

218220
ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new");
219221
Optional<HandlerFunction<ServerResponse>> handler = router.route(deepRequest);
@@ -229,13 +231,33 @@ void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception {
229231
@Test
230232
void spaDeepLinkFallback_ignoresApiFilesAndNonGet() {
231233
controller.init();
232-
RouterFunction<ServerResponse> router = controller.spaDeepLinkFallback();
234+
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
233235

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

241+
@Test
242+
void spaDeepLinkFallback_runsAfterControllersAndBeforeResources() {
243+
controller.init();
244+
int order = controller.spaDeepLinkFallbackMapping().getOrder();
245+
246+
// A catch-all denylist is only safe below every annotated controller; Spring's own
247+
// RouterFunctionMapping sits at -1, which would shadow /v1/api-docs, /error and friends.
248+
assertTrue(order > 0, "SPA fallback must run after annotated controllers");
249+
assertTrue(
250+
order < Ordered.LOWEST_PRECEDENCE - 1,
251+
"SPA fallback must run before the static-resource chain");
252+
}
253+
254+
private static RouterFunction<ServerResponse> routerOf(RouterFunctionMapping mapping) {
255+
@SuppressWarnings("unchecked")
256+
RouterFunction<ServerResponse> router =
257+
(RouterFunction<ServerResponse>) mapping.getRouterFunction();
258+
return router;
259+
}
260+
239261
private static ServerRequest serverRequest(String method, String uri) {
240262
MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, uri);
241263
ServletRequestPathUtils.parseAndCache(servletRequest);

0 commit comments

Comments
 (0)