mirror of
https://github.com/apache/struts.git
synced 2026-08-05 06:36:58 +00:00
WW-5640 Add WebJars support to Struts core (#1765)
* WW-5640 docs: design for WebJars support in Struts core Adds first-class WebJars support so client-side libraries can be referenced by a version-less logical path and served through the existing static-content pipeline. Grounded against 7.2.x source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 docs: implementation plan for WebJars support Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 build: add webjars-locator-lite dependency Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 feat: add webjars config constants and defaults Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 docs: correct plan test framework to JUnit 4 core uses JUnit 4 + AssertJ + Mockito, not JUnit 5 Jupiter (no Jupiter engine on the classpath). Test tasks translate accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 feat: add WebJarUrlProvider resolution seam Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 feat: register WebJarUrlProvider bean Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 feat: extend static content-type map for webjar assets Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 feat: serve webjar assets via static content loader Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 feat: add <s:webjar> tag and <@s.webjar> macro Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 docs: add generated tag reference for <s:webjar> Annotation-processor-generated tag reference (attributes + description), tracked like every other tag's docs under core/src/site/resources/tags/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 fix: address final review (log level, resolveUrl traversal test, javadoc) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WW-5640 refactor: address SonarCloud code smells - getContentType: replace long if/else chain with a static extension-> MIME map (S3776 cognitive complexity) - DefaultWebJarUrlProvider.split: return Optional<String[]> instead of a null sentinel (S1168; Optional fits the reject semantics, empty-array would not) - serving tests: rename local 'loader' -> 'webJarLoader' to stop hiding the ContentTypeProbe field (S1117) - WebJarTest: use assertThat(writer).hasToString(...) (S5838) S110 (WebJarTag inheritance depth) is inherent to the Struts tag base class hierarchy shared by every tag; left as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,17 @@
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>webjars-locator-lite</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>jquery</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
|
||||
@@ -195,6 +195,16 @@ public final class StrutsConstants {
|
||||
*/
|
||||
public static final String STRUTS_UI_STATIC_CONTENT_PATH = "struts.ui.staticContentPath";
|
||||
|
||||
/**
|
||||
* Whether WebJars support is enabled (serving and URL building)
|
||||
*/
|
||||
public static final String STRUTS_WEBJARS_ENABLED = "struts.webjars.enabled";
|
||||
|
||||
/**
|
||||
* Optional comma-separated allowlist of WebJar names permitted to be served (empty = all)
|
||||
*/
|
||||
public static final String STRUTS_WEBJARS_ALLOWLIST = "struts.webjars.allowlist";
|
||||
|
||||
/**
|
||||
* A global flag to enable/disable html body escaping in tags, can be overwritten per tag
|
||||
*/
|
||||
@@ -434,6 +444,11 @@ public final class StrutsConstants {
|
||||
*/
|
||||
public static final String STRUTS_STATIC_CONTENT_LOADER = "struts.staticContentLoader";
|
||||
|
||||
/**
|
||||
* The {@link org.apache.struts2.webjars.WebJarUrlProvider} implementation class
|
||||
*/
|
||||
public static final String STRUTS_WEBJARS_URL_PROVIDER = "struts.webjars.urlProvider";
|
||||
|
||||
/**
|
||||
* The {@link org.apache.struts2.UnknownHandlerManager} implementation class
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.components;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.struts2.inject.Inject;
|
||||
import org.apache.struts2.util.ValueStack;
|
||||
import org.apache.struts2.views.annotations.StrutsTag;
|
||||
import org.apache.struts2.views.annotations.StrutsTagAttribute;
|
||||
import org.apache.struts2.webjars.WebJarUrlProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* <p>Resolves a version-less WebJar resource path to a servable URL and writes it to the output
|
||||
* (or stores it in a variable when {@code var} is set). Compose it with {@code <s:script>}/{@code <s:link>}
|
||||
* or a raw {@code <link>}/{@code <script>} element.</p>
|
||||
*
|
||||
* <b>Examples</b>
|
||||
* <pre>
|
||||
* <link rel="stylesheet" href="<s:webjar path="bootstrap/css/bootstrap.min.css" />" />
|
||||
* <@s.webjar path="jquery/jquery.min.js"/>
|
||||
* </pre>
|
||||
*/
|
||||
@StrutsTag(
|
||||
name = "webjar",
|
||||
tldTagClass = "org.apache.struts2.views.jsp.WebJarTag",
|
||||
description = "Resolve a version-less WebJar resource path to a servable URL")
|
||||
public class WebJar extends ContextBean {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(WebJar.class);
|
||||
|
||||
protected String path;
|
||||
|
||||
private final HttpServletRequest request;
|
||||
private WebJarUrlProvider webJarUrlProvider;
|
||||
|
||||
public WebJar(ValueStack stack, HttpServletRequest request) {
|
||||
super(stack);
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
@Inject
|
||||
public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
|
||||
this.webJarUrlProvider = webJarUrlProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean end(Writer writer, String body) {
|
||||
String logicalPath = findString(path);
|
||||
Optional<String> url = (logicalPath == null)
|
||||
? Optional.empty()
|
||||
: webJarUrlProvider.resolveUrl(logicalPath, request);
|
||||
|
||||
if (url.isPresent()) {
|
||||
if (StringUtils.isNotBlank(getVar())) {
|
||||
putInContext(url.get());
|
||||
} else {
|
||||
try {
|
||||
writer.write(url.get());
|
||||
} catch (IOException e) {
|
||||
LOG.error("Could not write WebJar URL for path '{}'", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.end(writer, body);
|
||||
}
|
||||
|
||||
@StrutsTagAttribute(required = true,
|
||||
description = "The version-less WebJar resource path, e.g. bootstrap/css/bootstrap.min.css")
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ import org.apache.struts2.util.reflection.ReflectionProvider;
|
||||
import org.apache.struts2.validator.ActionValidatorManager;
|
||||
import org.apache.struts2.views.freemarker.FreemarkerManager;
|
||||
import org.apache.struts2.views.util.UrlHelper;
|
||||
import org.apache.struts2.webjars.WebJarUrlProvider;
|
||||
|
||||
/**
|
||||
* Selects the implementations of key framework extension points, using the loaded
|
||||
@@ -430,6 +431,7 @@ public class StrutsBeanSelectionProvider extends AbstractBeanSelectionProvider {
|
||||
alias(PatternMatcher.class, StrutsConstants.STRUTS_PATTERNMATCHER, builder, props);
|
||||
alias(ContentTypeMatcher.class, StrutsConstants.STRUTS_CONTENT_TYPE_MATCHER, builder, props);
|
||||
alias(StaticContentLoader.class, StrutsConstants.STRUTS_STATIC_CONTENT_LOADER, builder, props);
|
||||
alias(WebJarUrlProvider.class, StrutsConstants.STRUTS_WEBJARS_URL_PROVIDER, builder, props);
|
||||
alias(UnknownHandlerManager.class, StrutsConstants.STRUTS_UNKNOWN_HANDLER_MANAGER, builder, props);
|
||||
alias(UrlHelper.class, StrutsConstants.STRUTS_URL_HELPER, builder, props);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.struts2.StrutsConstants;
|
||||
import org.apache.struts2.webjars.WebJarUrlProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -39,6 +40,8 @@ import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,8 @@ import java.util.StringTokenizer;
|
||||
*/
|
||||
public class DefaultStaticContentLoader implements StaticContentLoader {
|
||||
|
||||
protected static final String WEBJARS_REQUEST_PREFIX = "/webjars/";
|
||||
|
||||
/**
|
||||
* Provide a logging instance.
|
||||
*/
|
||||
@@ -107,6 +112,13 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
|
||||
|
||||
protected boolean devMode;
|
||||
|
||||
protected WebJarUrlProvider webJarUrlProvider;
|
||||
|
||||
@Inject
|
||||
public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
|
||||
this.webJarUrlProvider = webJarUrlProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify state of StrutsConstants.STRUTS_SERVE_STATIC_CONTENT setting.
|
||||
*
|
||||
@@ -209,6 +221,14 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
|
||||
public void findStaticResource(String path, HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException {
|
||||
String name = cleanupPath(path);
|
||||
|
||||
if (name.startsWith(WEBJARS_REQUEST_PREFIX)) {
|
||||
if (!findWebJarResource(name, path, request, response)) {
|
||||
sendNotFound(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (String pathPrefix : pathPrefixes) {
|
||||
URL resourceUrl = findResource(buildPath(name, pathPrefix));
|
||||
if (resourceUrl != null) {
|
||||
@@ -231,6 +251,32 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
|
||||
}
|
||||
}
|
||||
|
||||
sendNotFound(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and serve a WebJar asset requested under {@code <staticContentPath>/webjars/**}.
|
||||
*
|
||||
* @param name the request path with the static-content prefix stripped, e.g. {@code /webjars/jquery/jquery.min.js}
|
||||
* @param path the original request path (used for content-type detection)
|
||||
* @return true if the asset was resolved and streamed; false otherwise (caller sends 404)
|
||||
*/
|
||||
protected boolean findWebJarResource(String name, String path, HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException {
|
||||
String logicalPath = name.substring(WEBJARS_REQUEST_PREFIX.length());
|
||||
Optional<String> resource = webJarUrlProvider.resolveResourcePath(logicalPath);
|
||||
if (resource.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
URL resourceUrl = findResource(resource.get());
|
||||
if (resourceUrl == null) {
|
||||
return false;
|
||||
}
|
||||
process(resourceUrl.openStream(), path, request, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void sendNotFound(HttpServletResponse response) {
|
||||
try {
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
} catch (IOException e1) {
|
||||
@@ -321,32 +367,43 @@ public class DefaultStaticContentLoader implements StaticContentLoader {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Maps a lower-case file extension to its content type. Not using the code provided by
|
||||
* activation.jar to avoid adding yet another dependency; this covers the files we serve up
|
||||
* (Struts' own static assets plus WebJar assets).
|
||||
*/
|
||||
private static final Map<String, String> CONTENT_TYPES = Map.ofEntries(
|
||||
Map.entry("js", "text/javascript"),
|
||||
Map.entry("mjs", "text/javascript"),
|
||||
Map.entry("css", "text/css"),
|
||||
Map.entry("html", "text/html"),
|
||||
Map.entry("txt", "text/plain"),
|
||||
Map.entry("gif", "image/gif"),
|
||||
Map.entry("jpg", "image/jpeg"),
|
||||
Map.entry("jpeg", "image/jpeg"),
|
||||
Map.entry("png", "image/png"),
|
||||
Map.entry("svg", "image/svg+xml"),
|
||||
Map.entry("ico", "image/x-icon"),
|
||||
Map.entry("woff2", "font/woff2"),
|
||||
Map.entry("woff", "font/woff"),
|
||||
Map.entry("ttf", "font/ttf"),
|
||||
Map.entry("otf", "font/otf"),
|
||||
Map.entry("eot", "application/vnd.ms-fontobject"),
|
||||
Map.entry("json", "application/json"),
|
||||
Map.entry("map", "application/json"));
|
||||
|
||||
/**
|
||||
* Determine the content type for the resource name.
|
||||
*
|
||||
* @param name The resource name
|
||||
* @return The mime type
|
||||
* @return The mime type, or {@code null} if the extension is unknown
|
||||
*/
|
||||
protected String getContentType(String name) {
|
||||
// NOT using the code provided activation.jar to avoid adding yet another dependency
|
||||
// this is generally OK, since these are the main files we server up
|
||||
if (name.endsWith(".js")) {
|
||||
return "text/javascript";
|
||||
} else if (name.endsWith(".css")) {
|
||||
return "text/css";
|
||||
} else if (name.endsWith(".html")) {
|
||||
return "text/html";
|
||||
} else if (name.endsWith(".txt")) {
|
||||
return "text/plain";
|
||||
} else if (name.endsWith(".gif")) {
|
||||
return "image/gif";
|
||||
} else if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
} else if (name.endsWith(".png")) {
|
||||
return "image/png";
|
||||
} else {
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return null;
|
||||
}
|
||||
return CONTENT_TYPES.get(name.substring(dot + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,7 @@ public class StrutsModels {
|
||||
protected RadioModel radio;
|
||||
protected SelectModel select;
|
||||
protected SetModel set;
|
||||
protected WebJarModel webjar;
|
||||
protected SubmitModel submit;
|
||||
protected ResetModel reset;
|
||||
protected TextAreaModel textarea;
|
||||
@@ -339,6 +340,13 @@ public class StrutsModels {
|
||||
return set;
|
||||
}
|
||||
|
||||
public WebJarModel getWebjar() {
|
||||
if (webjar == null) {
|
||||
webjar = new WebJarModel(stack, req, res);
|
||||
}
|
||||
return webjar;
|
||||
}
|
||||
|
||||
public PropertyModel getProperty() {
|
||||
if (property == null) {
|
||||
property = new PropertyModel(stack, req, res);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.views.freemarker.tags;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.struts2.components.Component;
|
||||
import org.apache.struts2.components.WebJar;
|
||||
import org.apache.struts2.util.ValueStack;
|
||||
|
||||
public class WebJarModel extends TagModel {
|
||||
|
||||
public WebJarModel(ValueStack stack, HttpServletRequest req, HttpServletResponse res) {
|
||||
super(stack, req, res);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Component getBean() {
|
||||
return new WebJar(stack, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.views.jsp;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.struts2.components.Component;
|
||||
import org.apache.struts2.components.WebJar;
|
||||
import org.apache.struts2.util.ValueStack;
|
||||
|
||||
public class WebJarTag extends ContextBeanTag {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
protected String path;
|
||||
|
||||
@Override
|
||||
public Component getBean(ValueStack stack, HttpServletRequest req, HttpServletResponse res) {
|
||||
return new WebJar(stack, req);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void populateParams() {
|
||||
super.populateParams();
|
||||
WebJar webJar = (WebJar) component;
|
||||
webJar.setPath(path);
|
||||
}
|
||||
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.webjars;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.apache.struts2.StrutsConstants;
|
||||
import org.apache.struts2.dispatcher.StaticContentLoader;
|
||||
import org.apache.struts2.inject.Inject;
|
||||
import org.webjars.WebJarVersionLocator;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Default {@link WebJarUrlProvider} backed by a singleton {@link WebJarVersionLocator}
|
||||
* (from {@code webjars-locator-lite}). Thread-safe.
|
||||
*/
|
||||
public class DefaultWebJarUrlProvider implements WebJarUrlProvider {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(DefaultWebJarUrlProvider.class);
|
||||
|
||||
private static final String WEBJARS_URL_SEGMENT = "/webjars/";
|
||||
|
||||
private final WebJarVersionLocator locator = new WebJarVersionLocator();
|
||||
|
||||
private boolean enabled = true;
|
||||
private Set<String> allowlist = Collections.emptySet();
|
||||
private String uiStaticContentPath = StaticContentLoader.DEFAULT_STATIC_CONTENT_PATH;
|
||||
|
||||
@Inject(value = StrutsConstants.STRUTS_WEBJARS_ENABLED, required = false)
|
||||
public void setEnabled(String enabled) {
|
||||
this.enabled = BooleanUtils.toBoolean(enabled);
|
||||
}
|
||||
|
||||
@Inject(value = StrutsConstants.STRUTS_WEBJARS_ALLOWLIST, required = false)
|
||||
public void setAllowlist(String allowlist) {
|
||||
Set<String> names = new HashSet<>();
|
||||
if (StringUtils.isNotBlank(allowlist)) {
|
||||
for (String name : allowlist.split(",")) {
|
||||
String trimmed = name.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
names.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.allowlist = Collections.unmodifiableSet(names);
|
||||
}
|
||||
|
||||
@Inject(StrutsConstants.STRUTS_UI_STATIC_CONTENT_PATH)
|
||||
public void setStaticContentPath(String uiStaticContentPath) {
|
||||
this.uiStaticContentPath = StaticContentLoader.Validator.validateStaticContentPath(uiStaticContentPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> resolveResourcePath(String logicalPath) {
|
||||
Optional<String[]> parts = split(logicalPath);
|
||||
if (parts.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String[] p = parts.get();
|
||||
String full = locator.fullPath(p[0], p[1]);
|
||||
if (full == null || !full.startsWith(WebJarVersionLocator.WEBJARS_PATH_PREFIX + "/")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(full);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> resolveUrl(String logicalPath, HttpServletRequest request) {
|
||||
Optional<String[]> parts = split(logicalPath);
|
||||
if (parts.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String[] p = parts.get();
|
||||
String versioned = locator.path(p[0], p[1]);
|
||||
if (versioned == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
StringBuilder url = new StringBuilder();
|
||||
String contextPath = request.getContextPath();
|
||||
if (StringUtils.isNotEmpty(contextPath) && !"/".equals(contextPath)) {
|
||||
url.append(contextPath);
|
||||
}
|
||||
url.append(uiStaticContentPath).append(WEBJARS_URL_SEGMENT).append(versioned);
|
||||
return Optional.of(url.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize, validate and split a logical path into {webJarName, filePath}.
|
||||
*
|
||||
* @return the two-element {webJarName, filePath} pair, or {@link Optional#empty()} if disabled,
|
||||
* blank, single-segment, traversal-tainted, or allowlist-blocked
|
||||
*/
|
||||
private Optional<String[]> split(String logicalPath) {
|
||||
if (!enabled || StringUtils.isBlank(logicalPath)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalized = StringUtils.stripStart(logicalPath, "/");
|
||||
if (normalized.contains("\\")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (String segment : normalized.split("/")) {
|
||||
if (segment.equals("..") || segment.equals(".")) {
|
||||
LOG.debug("Rejecting WebJar path with traversal segment: {}", logicalPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
int slash = normalized.indexOf('/');
|
||||
if (slash < 1 || slash == normalized.length() - 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String webJarName = normalized.substring(0, slash);
|
||||
String filePath = normalized.substring(slash + 1);
|
||||
if (!isAllowed(webJarName)) {
|
||||
LOG.debug("WebJar '{}' is not on the allowlist", webJarName);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new String[]{webJarName, filePath});
|
||||
}
|
||||
|
||||
private boolean isAllowed(String webJarName) {
|
||||
return allowlist.isEmpty() || allowlist.contains(webJarName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.webjars;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Resolves version-less WebJar logical paths (e.g. {@code bootstrap/css/bootstrap.min.css}) to either a
|
||||
* concrete classpath resource under {@code META-INF/resources/webjars/} (for serving) or a servable URL
|
||||
* (for tags/macros). Resolution is constrained to the WebJars root, honours an optional allowlist and the
|
||||
* {@code struts.webjars.enabled} switch, and fails closed (empty result) when unresolved or blocked.
|
||||
*/
|
||||
public interface WebJarUrlProvider {
|
||||
|
||||
/**
|
||||
* @param logicalPath version-less path such as {@code bootstrap/css/bootstrap.min.css}
|
||||
* @return the concrete classpath resource path (e.g.
|
||||
* {@code META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css}), or empty
|
||||
*/
|
||||
Optional<String> resolveResourcePath(String logicalPath);
|
||||
|
||||
/**
|
||||
* @param logicalPath version-less path such as {@code bootstrap/css/bootstrap.min.css}
|
||||
* @param request the current request (used for the servlet context path)
|
||||
* @return a servable URL, or empty
|
||||
*/
|
||||
Optional<String> resolveUrl(String logicalPath, HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* @return whether WebJars support is enabled
|
||||
*/
|
||||
boolean isEnabled();
|
||||
}
|
||||
@@ -106,6 +106,12 @@ struts.ui.staticContentPath=/static
|
||||
### headers)
|
||||
struts.serve.static.browserCache=true
|
||||
|
||||
### WebJars support
|
||||
### Master switch for resolving/serving WebJar assets under <staticContentPath>/webjars/**
|
||||
struts.webjars.enabled=true
|
||||
### Optional comma-separated allowlist of WebJar names (empty = all WebJars on the classpath)
|
||||
struts.webjars.allowlist=
|
||||
|
||||
### Set this to false if you wish to disable implicit dynamic method invocation
|
||||
### via the URL request. This includes URLs like foo!bar.action, as well as params
|
||||
### like method:bar (but not action:foo).
|
||||
|
||||
@@ -207,6 +207,8 @@
|
||||
|
||||
<bean type="org.apache.struts2.dispatcher.StaticContentLoader"
|
||||
class="org.apache.struts2.dispatcher.DefaultStaticContentLoader" name="struts"/>
|
||||
<bean type="org.apache.struts2.webjars.WebJarUrlProvider"
|
||||
class="org.apache.struts2.webjars.DefaultWebJarUrlProvider" name="struts"/>
|
||||
<bean type="org.apache.struts2.UnknownHandlerManager"
|
||||
class="org.apache.struts2.DefaultUnknownHandlerManager" name="struts"/>
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<table class="tag-reference">
|
||||
<tr>
|
||||
<td colspan="6"><h4>Dynamic Attributes Allowed:</h4> false</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="6"><hr/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="tag-header"><h4>Name</h4></th>
|
||||
<th class="tag-header"><h4>Required</h4></th>
|
||||
<th class="tag-header"><h4>Default</h4></th>
|
||||
<th class="tag-header"><h4>Evaluated</h4></th>
|
||||
<th class="tag-header"><h4>Type</h4></th>
|
||||
<th class="tag-header"><h4>Description</h4></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="tag-attribute">path</td>
|
||||
<td class="tag-attribute"><strong>true</strong></td>
|
||||
<td class="tag-attribute"></td>
|
||||
<td class="tag-attribute">false</td>
|
||||
<td class="tag-attribute">String</td>
|
||||
<td class="tag-attribute">The version-less WebJar resource path, e.g. bootstrap/css/bootstrap.min.css</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="tag-attribute">performClearTagStateForTagPoolingServers</td>
|
||||
<td class="tag-attribute">false</td>
|
||||
<td class="tag-attribute">false</td>
|
||||
<td class="tag-attribute">false</td>
|
||||
<td class="tag-attribute">Boolean</td>
|
||||
<td class="tag-attribute">Whether to clear all tag state during doEndTag() processing (if applicable)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="tag-attribute">var</td>
|
||||
<td class="tag-attribute">false</td>
|
||||
<td class="tag-attribute"></td>
|
||||
<td class="tag-attribute">false</td>
|
||||
<td class="tag-attribute">String</td>
|
||||
<td class="tag-attribute">Name used to reference the value pushed into the Value Stack (scope: action).</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1 @@
|
||||
Resolve a version-less WebJar resource path to a servable URL
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.components;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.struts2.util.ValueStack;
|
||||
import org.apache.struts2.webjars.WebJarUrlProvider;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.StringWriter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class WebJarTest {
|
||||
|
||||
private WebJar newComponent(ValueStack stack, HttpServletRequest request, WebJarUrlProvider provider) {
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
when(stack.getContext()).thenReturn(context);
|
||||
WebJar webJar = new WebJar(stack, request);
|
||||
webJar.setWebJarUrlProvider(provider);
|
||||
return webJar;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writesResolvedUrl() {
|
||||
ValueStack stack = mock(ValueStack.class);
|
||||
when(stack.findString("jquery/jquery.min.js")).thenReturn("jquery/jquery.min.js");
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
|
||||
when(provider.resolveUrl("jquery/jquery.min.js", request))
|
||||
.thenReturn(Optional.of("/myapp/static/webjars/jquery/3.7.1/jquery.min.js"));
|
||||
|
||||
WebJar webJar = newComponent(stack, request, provider);
|
||||
webJar.setPath("jquery/jquery.min.js");
|
||||
StringWriter writer = new StringWriter();
|
||||
|
||||
webJar.start(writer);
|
||||
webJar.end(writer, "");
|
||||
|
||||
assertThat(writer).hasToString("/myapp/static/webjars/jquery/3.7.1/jquery.min.js");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unresolvedPathWritesNothing() {
|
||||
ValueStack stack = mock(ValueStack.class);
|
||||
when(stack.findString("nope/x.js")).thenReturn("nope/x.js");
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
|
||||
when(provider.resolveUrl("nope/x.js", request)).thenReturn(Optional.empty());
|
||||
|
||||
WebJar webJar = newComponent(stack, request, provider);
|
||||
webJar.setPath("nope/x.js");
|
||||
StringWriter writer = new StringWriter();
|
||||
|
||||
webJar.start(writer);
|
||||
webJar.end(writer, "");
|
||||
|
||||
assertThat(writer.toString()).isEmpty();
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.dispatcher;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class DefaultStaticContentLoaderWebJarTest {
|
||||
|
||||
private final ContentTypeProbe loader = new ContentTypeProbe();
|
||||
|
||||
/** Exposes the protected getContentType for assertion. */
|
||||
static class ContentTypeProbe extends DefaultStaticContentLoader {
|
||||
String type(String name) {
|
||||
return getContentType(name);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapsWebJarAssetTypes() {
|
||||
assertThat(loader.type("x.woff2")).isEqualTo("font/woff2");
|
||||
assertThat(loader.type("x.woff")).isEqualTo("font/woff");
|
||||
assertThat(loader.type("x.ttf")).isEqualTo("font/ttf");
|
||||
assertThat(loader.type("x.otf")).isEqualTo("font/otf");
|
||||
assertThat(loader.type("x.eot")).isEqualTo("application/vnd.ms-fontobject");
|
||||
assertThat(loader.type("x.svg")).isEqualTo("image/svg+xml");
|
||||
assertThat(loader.type("x.map")).isEqualTo("application/json");
|
||||
assertThat(loader.type("x.json")).isEqualTo("application/json");
|
||||
assertThat(loader.type("x.ico")).isEqualTo("image/x-icon");
|
||||
assertThat(loader.type("x.mjs")).isEqualTo("text/javascript");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesExistingTypes() {
|
||||
assertThat(loader.type("x.js")).isEqualTo("text/javascript");
|
||||
assertThat(loader.type("x.css")).isEqualTo("text/css");
|
||||
assertThat(loader.type("x.png")).isEqualTo("image/png");
|
||||
assertThat(loader.type("x.unknown")).isNull();
|
||||
}
|
||||
|
||||
private DefaultStaticContentLoader newLoader(boolean enabled) {
|
||||
DefaultStaticContentLoader webJarLoader = new DefaultStaticContentLoader();
|
||||
webJarLoader.setServeStaticContent("true");
|
||||
webJarLoader.setStaticContentPath("/static");
|
||||
webJarLoader.setServeStaticBrowserCache("true");
|
||||
webJarLoader.setEncoding("UTF-8");
|
||||
org.apache.struts2.webjars.DefaultWebJarUrlProvider provider =
|
||||
new org.apache.struts2.webjars.DefaultWebJarUrlProvider();
|
||||
provider.setEnabled(String.valueOf(enabled));
|
||||
provider.setAllowlist("");
|
||||
provider.setStaticContentPath("/static");
|
||||
webJarLoader.setWebJarUrlProvider(provider);
|
||||
return webJarLoader;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void servesKnownWebJarAssetWithContentType() throws Exception {
|
||||
DefaultStaticContentLoader webJarLoader = newLoader(true);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
ByteArrayOutputStream captured = new ByteArrayOutputStream();
|
||||
when(response.getOutputStream())
|
||||
.thenReturn(new WebJarTestServletOutputStream(captured));
|
||||
|
||||
webJarLoader.findStaticResource("/static/webjars/jquery/jquery.min.js", request, response);
|
||||
|
||||
verify(response).setContentType("text/javascript");
|
||||
verify(response, never())
|
||||
.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
assertThat(captured.size()).isGreaterThan(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownWebJarAssetReturns404() throws Exception {
|
||||
DefaultStaticContentLoader webJarLoader = newLoader(true);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
|
||||
webJarLoader.findStaticResource("/static/webjars/nope/nope.js", request, response);
|
||||
|
||||
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disabledWebJarsReturns404() throws Exception {
|
||||
DefaultStaticContentLoader webJarLoader = newLoader(false);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
|
||||
webJarLoader.findStaticResource("/static/webjars/jquery/jquery.min.js", request, response);
|
||||
|
||||
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.dispatcher;
|
||||
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.WriteListener;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/** Minimal ServletOutputStream backed by an OutputStream, for tests. */
|
||||
public class WebJarTestServletOutputStream extends ServletOutputStream {
|
||||
|
||||
private final OutputStream delegate;
|
||||
|
||||
public WebJarTestServletOutputStream(OutputStream delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
delegate.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWriteListener(WriteListener writeListener) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.struts2.webjars;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class DefaultWebJarUrlProviderTest {
|
||||
|
||||
private DefaultWebJarUrlProvider provider;
|
||||
private HttpServletRequest request;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
provider = new DefaultWebJarUrlProvider();
|
||||
provider.setEnabled("true");
|
||||
provider.setAllowlist("");
|
||||
provider.setStaticContentPath("/static");
|
||||
request = mock(HttpServletRequest.class);
|
||||
when(request.getContextPath()).thenReturn("/myapp");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesKnownResourceToVersionedClasspathPath() {
|
||||
assertThat(provider.resolveResourcePath("jquery/jquery.min.js"))
|
||||
.hasValueSatisfying(p -> assertThat(p)
|
||||
.startsWith("META-INF/resources/webjars/jquery/")
|
||||
.endsWith("/jquery.min.js"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesKnownResourceToServableUrl() {
|
||||
assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
|
||||
.hasValueSatisfying(u -> assertThat(u)
|
||||
.startsWith("/myapp/static/webjars/jquery/")
|
||||
.endsWith("/jquery.min.js"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownWebjarResolvesEmpty() {
|
||||
assertThat(provider.resolveResourcePath("no-such-lib/x.js")).isEmpty();
|
||||
assertThat(provider.resolveUrl("no-such-lib/x.js", request)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void traversalIsRejected() {
|
||||
assertThat(provider.resolveResourcePath("jquery/../../../etc/passwd")).isEmpty();
|
||||
assertThat(provider.resolveResourcePath("../jquery/jquery.min.js")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveUrlRejectsTraversal() {
|
||||
assertThat(provider.resolveUrl("jquery/../../../etc/passwd", request)).isEmpty();
|
||||
assertThat(provider.resolveUrl("../jquery/jquery.min.js", request)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allowlistBlocksNonListedWebjar() {
|
||||
provider.setAllowlist("bootstrap");
|
||||
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allowlistPermitsListedWebjar() {
|
||||
provider.setAllowlist("jquery, bootstrap");
|
||||
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disabledResolvesEmpty() {
|
||||
provider.setEnabled("false");
|
||||
assertThat(provider.isEnabled()).isFalse();
|
||||
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
|
||||
assertThat(provider.resolveUrl("jquery/jquery.min.js", request)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootContextPathIsNotDuplicated() {
|
||||
when(request.getContextPath()).thenReturn("/");
|
||||
assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
|
||||
.hasValueSatisfying(u -> assertThat(u).startsWith("/static/webjars/jquery/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blankOrSingleSegmentPathResolvesEmpty() {
|
||||
assertThat(provider.resolveResourcePath("")).isEmpty();
|
||||
assertThat(provider.resolveResourcePath("jquery")).isEmpty();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
# Spec: WebJars support in Struts core
|
||||
|
||||
**Date:** 2026-07-01
|
||||
**JIRA:** [WW-5640](https://issues.apache.org/jira/browse/WW-5640)
|
||||
**Status:** Design approved — grounded against 7.2.x source; ready for implementation plan
|
||||
**Scope:** Framework (`core`) only. The consuming plugin work (struts2-bootstrap) is separate, in a separate repo, and starts only after this ships in a Struts release.
|
||||
|
||||
> ASF process note: commit messages must be prefixed with the ticket (`WW-5640 ...`). Before opening a PR, confirm the change is not a security patch (it is a feature) per `SECURITY.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Add first-class **WebJars** support to Struts core so client-side libraries packaged as WebJars (`org.webjars:*`, shipped under `META-INF/resources/webjars/<name>/<version>/…`) can be referenced from templates and tags by a **version-less logical path** and served through Struts' existing static-content pipeline.
|
||||
|
||||
Example: a template references `bootstrap/css/bootstrap.min.css`; Struts resolves and serves `META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`, and the emitted URL is `<ctx>/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Plugins and applications currently vendor client-side assets directly on the classpath and re-download/re-commit them on every upgrade. The struts2-bootstrap plugin, for example, commits ~2000 Bootstrap + bootstrap-icons files and re-vendors them by hand each release. WebJars replace this with a dependency bump (auto-updatable via Renovate/Dependabot). Struts should let plugins reference WebJar assets by a stable, version-less path and handle resolution + serving centrally.
|
||||
|
||||
**First consumer:** the struts2-bootstrap plugin (separate repo, separate work).
|
||||
|
||||
## Fixed decisions
|
||||
|
||||
| # | Decision | Choice | Rationale |
|
||||
|---|----------|--------|-----------|
|
||||
| Q0 | Serving model | **Struts serves** webjar assets through its static-content pipeline (`DefaultStaticContentLoader`), reusing content-type / caching handling. | Framework owns caching + security; works even where the servlet container does not auto-serve `META-INF/resources/`. Consistent with how Struts serves its bundled assets. |
|
||||
| 1 | Version resolution | **`org.webjars:webjars-locator-lite`** maps version-less path → versioned classpath resource. | Purpose-built for frameworks; MIT; single Apache-2.0 transitive dep; no Jackson/scanner; adopted by Spring Framework 6.2 for the same reasons. |
|
||||
| 2 | Public contract | **`WebJarUrlProvider` interface** (container-resolvable) **+ thin `<s:webjar>` tag and `<@s.webjar>` macro** on top. | Clean injectable Java seam for plugins + template ergonomics. |
|
||||
| 3 | URL prefix | Serve under the **existing static content path**: `<staticContentPath>/webjars/…`. | Reuses the already-wired static dispatcher; no new servlet mapping; single enable/disable switch. |
|
||||
| 4 | Security / allowlist | Hard-constrain resolution to the **`META-INF/resources/webjars/` root** with path normalization (block `..`). **Optional allowlist** of webjar names via a Struts config property; default = all webjars on classpath. | Struts streams classpath bytes, so traversal protection is mandatory; allowlist is opt-in defense-in-depth. |
|
||||
| 5 | Cache-busting | No query-param cache-buster; the resolved **version lives in the URL path**. | Versioned URLs are inherently cache-stable; no ETag needed beyond current behavior. |
|
||||
| 6 | Serving hook | **Add a `/webjars/` branch inside `DefaultStaticContentLoader`**, delegating resolution to `WebJarUrlProvider`, then reusing existing `process()`/caching. | Smallest change; single injected loader bean; no new multi-loader dispatch wiring. |
|
||||
| 7 | MIME coverage | **Extend `getContentType()`** with common webjar asset types (fonts, svg, source maps, json, ico). | Current map returns `null` for `.woff2/.ttf/.svg/.map/.eot/.ico/.json`, breaking web-font loading. Benefits existing static content too. |
|
||||
| 8 | Tag output | `<s:webjar>` / `<@s.webjar>` **emit the resolved URL string** (with optional `var` to store in the value stack). | Composes with the existing `<s:script>`/`<s:link>` tags; caller controls the element. |
|
||||
| 9 | Allowlist config | Struts config property **`struts.webjars.allowlist`** (comma-separated; empty = all). | Consistent with `struts.webjars.enabled` and the rest of the `struts.*` config surface. |
|
||||
| 10 | Target release | Land in the **next Struts 7.x minor**. | — |
|
||||
|
||||
## Architecture
|
||||
|
||||
One resolution seam, consumed by two callers (serving + URL-building):
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
<s:webjar> ─────▶│ │
|
||||
<@s.webjar> ────▶│ WebJarUrlProvider │───▶ WebJarVersionLocator
|
||||
│ (DefaultWebJarUrlProvider) │ (webjars-locator-lite,
|
||||
DefaultStatic ──▶│ │ singleton, cached)
|
||||
ContentLoader └─────────────────────────────┘
|
||||
(/webjars/ branch)
|
||||
```
|
||||
|
||||
### Grounding (verified against 7.2.x source)
|
||||
|
||||
- **Dispatch chain:** `StrutsPrepareAndExecuteFilter.tryHandleRequest` → `ExecuteOperations.executeStaticResourceRequest` → `StaticContentLoader.canHandle(path)` → `findStaticResource(path, req, res)`.
|
||||
- **Loader bean:** single container bean in `core/src/main/resources/struts-beans.xml`:
|
||||
`<bean type="org.apache.struts2.dispatcher.StaticContentLoader" class="org.apache.struts2.dispatcher.DefaultStaticContentLoader" name="struts"/>`.
|
||||
- **`canHandle`:** `serveStatic && resourcePath.startsWith(uiStaticContentPath + "/")` (`DefaultStaticContentLoader`).
|
||||
- **Fixed serving roots today:** `getAdditionalPackages()` → `org.apache.struts2.static`, `template`, `static` (+ debugging in devMode). Webjars need a distinct branch because resolution injects a **version**, so it is not just another package prefix.
|
||||
- **Caching:** `process()` sets caching headers gated on `serveStaticBrowserCache` (`struts.serve.static.browserCache`); uses a fixed `Last-Modified`, no ETag. Versioned URLs make this sufficient.
|
||||
- **Config pattern:** `StrutsConstants` (`public static final String struts.*`) + `core/src/main/resources/org/apache/struts2/default.properties` defaults + `@Inject(StrutsConstants.XXX)` setters.
|
||||
- **DI pattern:** interface + default impl registered in `struts-beans.xml` as `name="struts"`; obtained via `Container.getInstance(Type.class)` or `@Inject`.
|
||||
- **Tag pattern:** `Component` (or `ContextBean`) subclass + JSP `*Tag` (`getBean`/`populateParams`) + FreeMarker `*Model extends TagModel` (`getBean`) + registration in `StrutsModels` (field + getter). `<s:url>` builds context-aware URLs via `DefaultUrlHelper.buildUrl` / `request.getContextPath()`. `<s:script>`/`<s:link>` already render full elements pointing at static content.
|
||||
- **Locator API:** `org.webjars.WebJarVersionLocator` (webjars-locator-lite 1.1.3, MIT; one transitive dep `org.jspecify:jspecify:1.0.0`, Apache-2.0). `WEBJARS_PATH_PREFIX = "META-INF/resources/webjars"`. `fullPath(name, filePath)` → versioned classpath path or **`null`** when unresolved. Thread-safe; recommended as a singleton.
|
||||
|
||||
> Class/method names above are verified against the current branch but must be re-confirmed at implementation time (the tree moves).
|
||||
|
||||
## Components
|
||||
|
||||
### `WebJarUrlProvider` (public contract — R3)
|
||||
Public, stable interface in an appropriate `org.apache.struts2` package (e.g. `org.apache.struts2.views.webjars` — final package TBD during implementation, following neighbours). Container-registered so plugins depend on the interface, not internals.
|
||||
|
||||
Responsibilities:
|
||||
- **Resolve to classpath resource** (for serving): logical `<webjar>/<path>` → `META-INF/resources/webjars/<webjar>/<version>/<path>`, or empty/absent when unresolved or blocked.
|
||||
- **Resolve to servable URL** (for tags): `<contextPath> + <staticContentPath> + "/webjars/" + <webjar>/<version>/<path>`.
|
||||
- Apply the **allowlist** and the **enabled** switch.
|
||||
- Enforce **path normalization** and reject anything escaping `META-INF/resources/webjars/`.
|
||||
|
||||
`DefaultWebJarUrlProvider`:
|
||||
- Holds a singleton `WebJarVersionLocator` (constructed once; thread-safe).
|
||||
- `@Inject`s `struts.webjars.enabled`, `struts.webjars.allowlist`, `struts.ui.staticContentPath`.
|
||||
- Fail-closed: locator `null` → no resource / no URL emitted.
|
||||
|
||||
### Serving branch in `DefaultStaticContentLoader` (R2, Decision 6)
|
||||
- `canHandle` already matches `<staticContentPath>/...`; add recognition of the `/webjars/` sub-prefix and gate it additionally on `struts.webjars.enabled`.
|
||||
- In `findStaticResource`, when the cleaned path starts with `/webjars/`, strip the prefix, hand the logical `<webjar>/<path>` to `WebJarUrlProvider` for classpath resolution, obtain the resource URL, and stream it through the existing `process()` (reusing caching + the extended content-type map). Unresolved → **404** (fail closed); never fall through to arbitrary classpath serving.
|
||||
|
||||
### Content-type extension (Decision 7)
|
||||
Extend `getContentType()` to cover at least: `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf`, `.svg`, `.map` (→ `application/json`), `.json`, `.ico`, `.mjs` (→ `text/javascript`). Applies to all static content, not just webjars.
|
||||
|
||||
### `<s:webjar>` tag + `<@s.webjar>` macro (R3, Decision 8)
|
||||
- `WebJar` component (extends `ContextBean` to inherit `var` support) with a `path` attribute; `start`/`end` resolves via `WebJarUrlProvider` and emits the URL string, or stores it in `var` when set.
|
||||
- JSP `WebJarTag` (`getBean` + `populateParams`); FreeMarker `WebJarModel extends TagModel`; register field + getter in `StrutsModels`. TLD entry is generated from `@StrutsTag`/`@StrutsTagAttribute` annotations.
|
||||
- Unresolved path → emit nothing (fail closed).
|
||||
|
||||
### Configuration (R4, Decision 9)
|
||||
New `StrutsConstants` + `default.properties`:
|
||||
- `struts.webjars.enabled=true` — master switch.
|
||||
- `struts.webjars.allowlist=` — optional comma-separated webjar names; empty = all.
|
||||
|
||||
Reuse existing `struts.serve.static`, `struts.serve.static.browserCache`, `struts.ui.staticContentPath`. No new prefix constant.
|
||||
|
||||
### Dependency (R6)
|
||||
Add `org.webjars:webjars-locator-lite` (1.1.x, currently 1.1.3) to `core`. Manage the version in `bom`/`parent` per project convention. Transitive footprint: `org.jspecify:jspecify` only (Apache-2.0). Both Category A — no ASF licensing issue.
|
||||
|
||||
## Security (R5)
|
||||
|
||||
- Normalize the requested path and **reject any resolved path escaping `META-INF/resources/webjars/`** (block `..` traversal and encoded variants; the loader already `URLDecoder.decode`s in `buildPath`, so normalize post-decode).
|
||||
- Only serve resources whose resolved path comes from the locator (a known webjar); never raw classpath lookups.
|
||||
- Honour the optional allowlist.
|
||||
- Honour `struts.webjars.enabled=false` (both serving and URL emission become inert).
|
||||
|
||||
## Data flow
|
||||
|
||||
**Serving:** request `<ctx>/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css` → filter → `canHandle` (static + `/webjars/` + enabled) → `findStaticResource` strips `/static`, sees `/webjars/…`, asks `WebJarUrlProvider` to resolve to `META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`, validates containment, `findResource`, `process()` → 200 + content-type + caching headers. Unresolved/blocked → 404.
|
||||
|
||||
**URL building:** `<s:webjar path="bootstrap/css/bootstrap.min.css"/>` → component → `WebJarUrlProvider` resolves version and composes `<ctx>/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css` → emitted string (or stored in `var`). Typically wrapped by the caller: `<s:link href="%{webjarUrl}"/>` or `<link rel="stylesheet" href="...">`.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Unresolved webjar/path:** serving → 404; URL building → empty output / absent `var`. Never throw to the user; log at debug.
|
||||
- **Disabled:** `canHandle` returns false for `/webjars/`; tag emits nothing.
|
||||
- **Traversal / out-of-root:** treated as unresolved → 404 / empty.
|
||||
- **Locator construction failure:** provider degrades to fail-closed; logged.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit — resolution (`DefaultWebJarUrlProvider`):** known path → expected versioned URL; unknown webjar/path → empty; traversal (`bootstrap/../../../etc/passwd`, encoded `..`) → rejected; allowlist blocks a non-listed webjar; `enabled=false` → inert.
|
||||
- **Unit — content-type:** each new extension maps to the expected MIME type.
|
||||
- **Integration — serving:** request a served webjar asset → 200 + correct content-type + caching headers governed by `struts.serve.static.browserCache`; `struts.webjars.enabled=false` → 404/not served; unknown asset → 404.
|
||||
- **Tag/macro:** `<s:webjar>` and `<@s.webjar>` render the resolved URL; `var` stores it; unresolved → empty.
|
||||
|
||||
Use JUnit 5 + AssertJ + Mockito, per project convention. A test webjar (e.g. a small `org.webjars` artifact) is added as a `test` dependency to `core` for integration coverage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Container-served model (relying on Servlet `META-INF/resources` auto-serving) — rejected in favour of Q0.
|
||||
- ETag support / per-resource `Last-Modified` — versioned URLs make it unnecessary.
|
||||
- The consuming plugin's changes (separate work, separate repo).
|
||||
- Any `<s:script>`/`<s:link>` convenience overload that auto-emits an element from a webjar path — possible follow-up; this spec emits URL strings only.
|
||||
|
||||
## Confirm during implementation
|
||||
|
||||
- Final package for `WebJarUrlProvider` and the tag classes (follow existing neighbours).
|
||||
- Exact `canHandle`/`findStaticResource` edit points against the then-current `DefaultStaticContentLoader`.
|
||||
- `webjars-locator-lite` version pinned via `bom`/`parent`; re-check latest 1.1.x at implementation time.
|
||||
- Whether `struts.webjars.allowlist` matches on webjar name only (assumed) vs. name+path prefix.
|
||||
@@ -54,6 +54,18 @@
|
||||
<version>3.2.4</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>webjars-locator-lite</artifactId>
|
||||
<version>${webjars-locator-lite.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>jquery</artifactId>
|
||||
<version>${webjars-jquery.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Velocity -->
|
||||
<dependency>
|
||||
<groupId>org.apache.velocity</groupId>
|
||||
|
||||
@@ -129,6 +129,8 @@
|
||||
<spring.version>6.2.12</spring.version>
|
||||
<struts-annotations.version>2.0</struts-annotations.version>
|
||||
<velocity-tools.version>3.1</velocity-tools.version>
|
||||
<webjars-jquery.version>3.7.1</webjars-jquery.version>
|
||||
<webjars-locator-lite.version>1.1.3</webjars-locator-lite.version>
|
||||
<weld.version>6.0.4.Final</weld.version>
|
||||
|
||||
<!-- Site generation -->
|
||||
|
||||
Reference in New Issue
Block a user