diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java index ce96bbd17f..db1e718c5d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointInterceptor.java @@ -21,6 +21,13 @@ public boolean preHandle( HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); + + // Prevent API responses from being stored by browsers or intermediary caches by default + String servletPath = request.getServletPath(); + if (servletPath != null && servletPath.startsWith("/api/")) { + response.setHeader("Cache-Control", "private, no-store"); + } + boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI); if (!isEnabled) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled"); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 59790ee5d4..8a84cf04cd 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -1,5 +1,6 @@ package stirling.software.SPDF.config; +import java.time.Duration; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; @@ -24,6 +25,10 @@ public class WebMvcConfig implements WebMvcConfigurer { private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class); + private static final CacheControl NO_CACHE = CacheControl.noCache(); + private static final CacheControl IMMUTABLE_ONE_YEAR = + CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable(); + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(endpointInterceptor); @@ -31,37 +36,73 @@ public void addInterceptors(InterceptorRegistry registry) { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { - // Cache hashed assets (JS/CSS with content hashes) for 1 year - // These files have names like index-ChAS4tCC.js that change when content changes - // Check customFiles/static first, then fall back to classpath + String staticPath = + "file:" + + stirling.software.common.configuration.InstallationPathConfig + .getStaticPath(); + + // 1. Service worker and PWA metadata (never store) + // Browsers revalidate SW bytes anyway; no-store is the safest for atomic updates. + registry.addResourceHandler( + "/sw.js", "/manifest.json", "/site.webmanifest", "/browserconfig.xml") + .addResourceLocations(staticPath, "classpath:/static/") + .setCacheControl(CacheControl.noStore()) + .resourceChain(true); + + // 2. Vite fingerprinted assets (immutable) + // These already have content hashes in filenames (e.g. index-ChAS4tCC.js) registry.addResourceHandler("/assets/**") + .addResourceLocations(staticPath + "assets/", "classpath:/static/assets/") + .setCacheControl(IMMUTABLE_ONE_YEAR) + .resourceChain(true); + + // 3. Media and fonts (immutable) + registry.addResourceHandler("/images/**", "/fonts/**") .addResourceLocations( - "file:" - + stirling.software.common.configuration.InstallationPathConfig - .getStaticPath() - + "assets/", - "classpath:/static/assets/") - .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()); - - // Don't cache index.html - it needs to be fresh to reference latest hashed assets - // Note: index.html is handled by ReactRoutingController for dynamic processing - registry.addResourceHandler("/index.html") + staticPath + "images/", + "classpath:/static/images/", + staticPath + "fonts/", + "classpath:/static/fonts/") + .setCacheControl(IMMUTABLE_ONE_YEAR) + .resourceChain(true); + + // 4. Branding and stable non-fingerprinted assets (1 day + SWR) + // Use stale-while-revalidate to improve perceived performance. + registry.addResourceHandler( + "/favicon.*", + "/apple-touch-icon.png", + "/android-chrome-*.png", + "/mstile-*.png", + "/safari-pinned-tab.svg", + "/icons/**", + "/modern-logo/**", + "/classic-logo/**", + "/robots.txt", + "/3rdPartyLicenses.json", + "/pdfjs/**", + "/pdfjs-legacy/**", + "/pdfium/**") .addResourceLocations( - "file:" - + stirling.software.common.configuration.InstallationPathConfig - .getStaticPath(), - "classpath:/static/") - .setCacheControl(CacheControl.noCache().mustRevalidate()); - - // Handle all other static resources (js, css, images, fonts, etc.) - // Check customFiles/static first for user overrides + staticPath, + "classpath:/static/", + staticPath + "pdfjs/", + "classpath:/static/pdfjs/", + staticPath + "pdfjs-legacy/", + "classpath:/static/pdfjs-legacy/", + staticPath + "pdfium/", + "classpath:/static/pdfium/") + .setCacheControl( + CacheControl.maxAge(Duration.ofDays(1)) + .cachePublic() + .staleWhileRevalidate(Duration.ofDays(7))) + .resourceChain(true); + + // 5. Catch-all (SPA fallback) + // Must check with server to ensure index.html is always fresh. registry.addResourceHandler("/**") - .addResourceLocations( - "file:" - + stirling.software.common.configuration.InstallationPathConfig - .getStaticPath(), - "classpath:/static/") - .setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)); + .addResourceLocations(staticPath, "classpath:/static/") + .setCacheControl(NO_CACHE) + .resourceChain(true); } @Override 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 02fa9b8169..39d6352af0 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 @@ -12,6 +12,7 @@ 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.stereotype.Controller; @@ -134,13 +135,22 @@ private Resource getIndexHtmlResource() { public ResponseEntity serveIndexHtml(HttpServletRequest request) { try { if (indexHtmlExists && cachedIndexHtml != null) { - return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml); + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(cachedIndexHtml); } // Fallback: process on each request (dev mode or cache failed) - return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml()); + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(processIndexHtml()); } catch (Exception ex) { log.error("Failed to serve index.html, returning fallback", ex); - return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml()); + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(buildFallbackHtml()); } } diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 701adb2c2c..b5777e1a18 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -33,7 +33,7 @@ spring.security.filter.dispatcher-types=REQUEST,ERROR # Response compression server.compression.enabled=true server.compression.min-response-size=1024 -server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript +server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript,image/svg+xml,application/x-font-ttf,font/opentype,application/vnd.ms-fontobject,font/woff,font/woff2,application/font-woff,application/font-woff2 spring.web.error.path=/error spring.web.error.whitelabel.enabled=false