|
5 | 5 | import java.nio.charset.StandardCharsets; |
6 | 6 | import java.nio.file.Files; |
7 | 7 | import java.nio.file.Path; |
| 8 | +import java.util.List; |
8 | 9 | import java.util.Set; |
9 | 10 | import java.util.regex.Pattern; |
10 | 11 |
|
11 | 12 | import org.springframework.beans.factory.annotation.Value; |
12 | 13 | import org.springframework.context.annotation.Bean; |
| 14 | +import org.springframework.core.Ordered; |
13 | 15 | import org.springframework.core.io.ClassPathResource; |
14 | 16 | import org.springframework.core.io.FileSystemResource; |
15 | 17 | import org.springframework.core.io.Resource; |
16 | 18 | import org.springframework.http.CacheControl; |
17 | 19 | import org.springframework.http.MediaType; |
18 | 20 | import org.springframework.http.ResponseEntity; |
| 21 | +import org.springframework.http.converter.StringHttpMessageConverter; |
19 | 22 | import org.springframework.stereotype.Controller; |
20 | 23 | import org.springframework.web.bind.annotation.GetMapping; |
21 | 24 | import org.springframework.web.servlet.function.RouterFunction; |
22 | 25 | import org.springframework.web.servlet.function.RouterFunctions; |
23 | 26 | import org.springframework.web.servlet.function.ServerResponse; |
| 27 | +import org.springframework.web.servlet.function.support.RouterFunctionMapping; |
24 | 28 | import org.springframework.web.util.HtmlUtils; |
25 | 29 | import org.springframework.web.util.JavaScriptUtils; |
26 | 30 |
|
@@ -60,6 +64,10 @@ public class ReactRoutingController { |
60 | 64 | "og_images", |
61 | 65 | "samples"); |
62 | 66 |
|
| 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 | + |
63 | 71 | @Value("${server.servlet.context-path:/}") |
64 | 72 | private String contextPath; |
65 | 73 |
|
@@ -286,26 +294,40 @@ public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request) |
286 | 294 |
|
287 | 295 | // The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot |
288 | 296 | // 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. |
289 | 304 | @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; |
305 | 326 | } |
306 | 327 |
|
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. |
309 | 331 | static boolean isSpaFallbackRoute(String path) { |
310 | 332 | if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) { |
311 | 333 | return false; |
|
0 commit comments