Skip to content

Commit 3ffda66

Browse files
Artur-mcollovati
andauthored
fix: Normalize bare stylesheet paths to prevent MalformedURLException warnings (#24029)
Bare paths like "lumo/lumo.css" from @Stylesheet annotations cause a MalformedURLException warning because ServletContext.getResource() requires a leading '/'. Normalize the path in ResourceContentHash before calling getStaticResource(), and document the '/' requirement in VaadinService.getStaticResource() javadoc. Fixes #24028 --------- Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent a74df1a commit 3ffda66

5 files changed

Lines changed: 83 additions & 24 deletions

File tree

flow-server/src/main/java/com/vaadin/flow/internal/ResourceContentHash.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import java.io.IOException;
1919
import java.io.InputStream;
20+
import java.net.URI;
2021
import java.net.URL;
2122
import java.util.concurrent.ConcurrentHashMap;
2223

@@ -98,13 +99,10 @@ private static String computeHash(VaadinService service,
9899
private static InputStream openResource(VaadinService service,
99100
String resourceUrl) {
100101
String resolved = service.resolveResource(resourceUrl);
101-
URL url = service.getStaticResource(resolved);
102-
// Bare paths (e.g. "styles.css") may not resolve in the servlet
103-
// context which requires a leading '/'. Try with '/' prefix.
104-
if (url == null && !resolved.startsWith("/")
105-
&& !resolved.contains("://")) {
106-
url = service.getStaticResource("/" + resolved);
102+
if (!resolved.startsWith("/") && !resolved.contains("://")) {
103+
resolved = URI.create("/" + resolved).normalize().getPath();
107104
}
105+
URL url = service.getStaticResource(resolved);
108106
if (url == null) {
109107
logger.debug(
110108
"Could not find static resource for '{}' "

flow-server/src/main/java/com/vaadin/flow/server/VaadinService.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,10 +2554,12 @@ public void fireUIInitListeners(UI ui) {
25542554

25552555
/**
25562556
* Returns a URL to the static resource at the given URI or null if no file
2557-
* found.
2557+
* found. The path must start with a {@code /} character for servlet-based
2558+
* implementations, as required by the Jakarta Servlet specification for
2559+
* {@code ServletContext.getResource()}.
25582560
*
25592561
* @param url
2560-
* the URL for the resource
2562+
* the URL for the resource, must start with {@code /}
25612563
* @return the resource located at the named path, or <code>null</code> if
25622564
* there is no resource at that path
25632565
*/

flow-server/src/test/java/com/vaadin/flow/internal/ResourceContentHashTest.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ void getContentHash_knownContent_returnsExpectedHash() throws Exception {
5656
URL url = createTempResource("body { color: red; }");
5757
Mockito.when(service.resolveResource("styles.css"))
5858
.thenReturn("styles.css");
59-
Mockito.when(service.getStaticResource("styles.css")).thenReturn(url);
59+
Mockito.when(service.getStaticResource("/styles.css")).thenReturn(url);
6060

6161
String hash = ResourceContentHash.getContentHash(service, "styles.css");
6262

@@ -100,19 +100,17 @@ void getContentHash_blankUrl_returnsNull() {
100100
void getContentHash_missingResource_returnsNull() {
101101
Mockito.when(service.resolveResource("missing.css"))
102102
.thenReturn("missing.css");
103-
Mockito.when(service.getStaticResource("missing.css")).thenReturn(null);
104103
Mockito.when(service.getStaticResource("/missing.css"))
105104
.thenReturn(null);
106105

107106
assertNull(ResourceContentHash.getContentHash(service, "missing.css"));
108107
}
109108

110109
@Test
111-
void getContentHash_barePath_fallsBackToSlashPrefixed() throws Exception {
110+
void getContentHash_barePath_normalizedWithSlashPrefix() throws Exception {
112111
URL url = createTempResource("body { color: blue; }");
113112
Mockito.when(service.resolveResource("bare.css"))
114113
.thenReturn("bare.css");
115-
Mockito.when(service.getStaticResource("bare.css")).thenReturn(null);
116114
Mockito.when(service.getStaticResource("/bare.css")).thenReturn(url);
117115

118116
String hash = ResourceContentHash.getContentHash(service, "bare.css");
@@ -125,7 +123,7 @@ void getContentHash_cachedAfterFirstCall() throws Exception {
125123
URL url = createTempResource("body {}");
126124
Mockito.when(service.resolveResource("cached.css"))
127125
.thenReturn("cached.css");
128-
Mockito.when(service.getStaticResource("cached.css")).thenReturn(url);
126+
Mockito.when(service.getStaticResource("/cached.css")).thenReturn(url);
129127

130128
String hash1 = ResourceContentHash.getContentHash(service,
131129
"cached.css");
@@ -135,7 +133,7 @@ void getContentHash_cachedAfterFirstCall() throws Exception {
135133
assertEquals(hash1, hash2);
136134
// Resource URL should only be looked up once due to caching
137135
Mockito.verify(service, Mockito.times(1))
138-
.getStaticResource("cached.css");
136+
.getStaticResource("/cached.css");
139137
}
140138

141139
}

flow-server/src/test/java/com/vaadin/flow/server/AppShellRegistryStyleSheetDataFilePathTest.java

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,12 @@ void productionMode_hrefContainsHash_dataFilePathUnchanged()
108108
mocks.getDeploymentConfiguration().setProductionMode(true);
109109

110110
// Register stylesheet resources so the hash can be computed.
111-
// Paths must match what resolveResource() produces for each
112-
// annotation value.
111+
// Paths use leading '/' as required by ServletContext.getResource().
113112
mocks.getServlet().addServletContextResource("/absolute.css",
114113
"body { color: red; }");
115114
mocks.getServlet().addServletContextResource("/from-context.css",
116115
"body { color: blue; }");
117-
// ./relative/path.css is passed through resolveResource unchanged
118-
mocks.getServlet().addServletContextResource("./relative/path.css",
116+
mocks.getServlet().addServletContextResource("/relative/path.css",
119117
"body { color: green; }");
120118

121119
AppShellRegistry registry = AppShellRegistry.getInstance(context);
@@ -170,8 +168,7 @@ void productionMode_hrefContainsHash_dataFilePathUnchanged()
170168
}
171169

172170
@Test
173-
void modifyIndex_customServletMapping_hrefIsServletRelative()
174-
throws Exception {
171+
void modifyIndex_customServletMapping_hrefIsServletRelative() {
175172
AppShellRegistry registry = AppShellRegistry.getInstance(context);
176173
registry.setShell(MyShell.class);
177174

@@ -195,6 +192,70 @@ void modifyIndex_customServletMapping_hrefIsServletRelative()
195192
links.get(3).attr("href"));
196193
}
197194

195+
@Test
196+
void productionMode_modifyIndex_customServletMapping_hrefIsServletRelative() {
197+
198+
// Register stylesheet resources so the hash can be computed.
199+
// Paths use leading '/' as required by ServletContext.getResource().
200+
mocks.getServlet().addServletContextResource("/absolute.css",
201+
"body { color: red; }");
202+
mocks.getServlet().addServletContextResource("/from-context.css",
203+
"body { color: blue; }");
204+
mocks.getServlet().addServletContextResource("/relative/path.css",
205+
"body { color: green; }");
206+
207+
Pattern hashPattern = Pattern
208+
.compile("\\?" + ApplicationConstants.CONTENT_HASH_PARAMETER
209+
+ "=[0-9a-f]{8}$");
210+
211+
mocks.getDeploymentConfiguration().setProductionMode(true);
212+
AppShellRegistry registry = AppShellRegistry.getInstance(context);
213+
registry.setShell(MyShell.class);
214+
215+
// Servlet mapped to "/myservlet/*", context path "/ctx".
216+
// contextRootRelativePath becomes "./../" so relative and
217+
// context:// hrefs must step one level up from the servlet path.
218+
VaadinServletRequest request = createRequest("/", "/ctx", "/myservlet");
219+
registry.modifyIndexHtml(document, request);
220+
221+
List<Element> links = document.head().select("link[rel=stylesheet]");
222+
assertEquals(4, links.size());
223+
224+
// 1) Absolute path: href has hash appended, data-file-path unchanged
225+
Element abs = links.get(0);
226+
assertTrue(hashPattern.matcher(abs.attr("href")).find(),
227+
"Absolute href should contain hash parameter");
228+
assertTrue(abs.attr("href").startsWith("/absolute.css"),
229+
"Absolute href should start with /absolute.css");
230+
assertEquals("/absolute.css", abs.attr("data-file-path"));
231+
232+
// 2) Relative path: href is servlet-relative, hash appended,
233+
// data-file-path unchanged
234+
Element rel = links.get(1);
235+
assertTrue(hashPattern.matcher(rel.attr("href")).find(),
236+
"Relative href should contain hash parameter");
237+
assertTrue(rel.attr("href").startsWith("./../relative/path.css"),
238+
"Relative href should start with ./../");
239+
assertEquals("./relative/path.css", rel.attr("data-file-path"));
240+
241+
// 3) Context path: href is servlet-relative (context:// expanded),
242+
// hash appended, data-file-path unchanged
243+
Element ctx = links.get(2);
244+
assertTrue(hashPattern.matcher(ctx.attr("href")).find(),
245+
"Context href should contain hash parameter");
246+
assertTrue(ctx.attr("href").startsWith("./../from-context.css"),
247+
"Context href should start with ./../");
248+
assertEquals("context://from-context.css", ctx.attr("data-file-path"));
249+
250+
// 4) External URL: no hash appended, data-file-path unchanged
251+
Element remote = links.get(3);
252+
assertEquals("https://cdn.example.com/remote.css", remote.attr("href"));
253+
assertFalse(hashPattern.matcher(remote.attr("href")).find(),
254+
"External href should not have hash");
255+
assertEquals("https://cdn.example.com/remote.css",
256+
remote.attr("data-file-path"));
257+
}
258+
198259
@Test
199260
void productionMode_missingResource_fallsBackToOriginalUrl()
200261
throws Exception {

flow-server/src/test/java/com/vaadin/flow/server/communication/UidlWriterTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,11 +353,11 @@ void productionMode_stylesheetDependency_urlContainsHash()
353353
UI ui = initializeUIForDependenciesTest(new TestUI());
354354
mocks.getDeploymentConfiguration().setProductionMode(true);
355355

356-
// Add resources so hash can be computed. Paths must match what
357-
// resolveResource() produces for the @StyleSheet annotation values.
358-
mocks.getServlet().addServletContextResource("eager.css",
356+
// Add resources so hash can be computed. Paths use leading '/' as
357+
// required by ServletContext.getResource() per the servlet spec.
358+
mocks.getServlet().addServletContextResource("/eager.css",
359359
"body { color: red; }");
360-
mocks.getServlet().addServletContextResource("lazy.css",
360+
mocks.getServlet().addServletContextResource("/lazy.css",
361361
"body { color: blue; }");
362362

363363
UidlWriter uidlWriter = new UidlWriter();

0 commit comments

Comments
 (0)