diff --git a/opennms-web-api/src/main/java/org/opennms/web/servlet/SpaRoutingFilter.java b/opennms-web-api/src/main/java/org/opennms/web/servlet/SpaRoutingFilter.java index 7a37490a2457..e602c40b82cd 100644 --- a/opennms-web-api/src/main/java/org/opennms/web/servlet/SpaRoutingFilter.java +++ b/opennms-web-api/src/main/java/org/opennms/web/servlet/SpaRoutingFilter.java @@ -22,6 +22,7 @@ package org.opennms.web.servlet; import java.io.IOException; +import java.util.regex.Pattern; import javax.servlet.Filter; import javax.servlet.FilterChain; @@ -31,31 +32,41 @@ import javax.servlet.ServletResponse; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; @WebFilter(asyncSupported = true, urlPatterns = "/*") public class SpaRoutingFilter implements Filter { + public static final String UI_VERSION_PROPERTY = "org.opennms.web.ui.version"; + public static final String DEFAULT_VERSION = "default"; + + private static final Pattern SAFE_VERSION = Pattern.compile("[A-Za-z0-9._-]+"); + @Override public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException { final HttpServletRequest httpServletRequest = (HttpServletRequest) request; final String uri = httpServletRequest.getRequestURI().substring(httpServletRequest.getContextPath().length()); - if (isClientPath(uri)) { - chain.doFilter(new HttpServletRequestWrapper(httpServletRequest) { - @Override - public String getServletPath() { - return "/ui/index.html"; - } - }, response); + + if (!uri.equals("/ui") && !uri.startsWith("/ui/")) { + chain.doFilter(request, response); return; } - chain.doFilter(request, response); + + final String version = activeVersion(); + final String suffix = isAsset(uri) ? uri.substring("/ui".length()) : "/index.html"; + final String target = "/ui-versions/" + version + suffix; + request.getRequestDispatcher(target).forward(request, response); + } + + private String activeVersion() { + final String value = System.getProperty(UI_VERSION_PROPERTY, DEFAULT_VERSION); + if (value.contains("..") || !SAFE_VERSION.matcher(value).matches()) { + return DEFAULT_VERSION; + } + return value; } - private boolean isClientPath(final String uri) { - return uri.startsWith("/ui/") - && !uri.startsWith("/ui/assets/") - && !uri.endsWith(".svg"); + private boolean isAsset(final String uri) { + return uri.startsWith("/ui/assets/") || uri.endsWith(".svg"); } @Override diff --git a/opennms-web-api/src/test/java/org/opennms/web/servlet/SpaRoutingFilterTest.java b/opennms-web-api/src/test/java/org/opennms/web/servlet/SpaRoutingFilterTest.java new file mode 100644 index 000000000000..efacc07ea113 --- /dev/null +++ b/opennms-web-api/src/test/java/org/opennms/web/servlet/SpaRoutingFilterTest.java @@ -0,0 +1,109 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.web.servlet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.After; +import org.junit.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +public class SpaRoutingFilterTest { + + private static final String PROP = "org.opennms.web.ui.version"; + + @After + public void clearProperty() { + System.clearProperty(PROP); + } + + private MockHttpServletResponse runFilter(String uri, MockFilterChain chain) throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", uri); + MockHttpServletResponse res = new MockHttpServletResponse(); + new SpaRoutingFilter().doFilter(req, res, chain); + return res; + } + + @Test + public void defaultVersion_spaDeepLink_forwardsToDefaultIndex() throws Exception { + MockFilterChain chain = new MockFilterChain(); + MockHttpServletResponse res = runFilter("/ui/dashboard", chain); + assertEquals("/ui-versions/default/index.html", res.getForwardedUrl()); + assertNull("filter chain should not be invoked when forwarding", chain.getRequest()); + } + + @Test + public void namedVersion_spaDeepLink_forwardsToActiveVersionIndex() throws Exception { + System.setProperty(PROP, "next"); + MockHttpServletResponse res = runFilter("/ui/dashboard", new MockFilterChain()); + assertEquals("/ui-versions/next/index.html", res.getForwardedUrl()); + } + + @Test + public void namedVersion_assetRequest_forwardsToActiveVersionAsset() throws Exception { + System.setProperty(PROP, "next"); + MockHttpServletResponse res = runFilter("/ui/assets/index-abc123.js", new MockFilterChain()); + assertEquals("/ui-versions/next/assets/index-abc123.js", res.getForwardedUrl()); + } + + @Test + public void namedVersion_svgAsset_forwardsToActiveVersionSvg() throws Exception { + System.setProperty(PROP, "next"); + MockHttpServletResponse res = runFilter("/ui/logo.svg", new MockFilterChain()); + assertEquals("/ui-versions/next/logo.svg", res.getForwardedUrl()); + } + + @Test + public void dottedVersionName_isAccepted() throws Exception { + System.setProperty(PROP, "35.1.0-beta"); + MockHttpServletResponse res = runFilter("/ui/dashboard", new MockFilterChain()); + assertEquals("/ui-versions/35.1.0-beta/index.html", res.getForwardedUrl()); + } + + @Test + public void slashInVersionName_fallsBackToDefault() throws Exception { + System.setProperty(PROP, "../etc/passwd"); + MockHttpServletResponse res = runFilter("/ui/dashboard", new MockFilterChain()); + assertEquals("/ui-versions/default/index.html", res.getForwardedUrl()); + } + + @Test + public void dotDotInVersionName_fallsBackToDefault() throws Exception { + System.setProperty(PROP, ".."); + MockHttpServletResponse res = runFilter("/ui/dashboard", new MockFilterChain()); + assertEquals("/ui-versions/default/index.html", res.getForwardedUrl()); + } + + @Test + public void nonUiRequest_passesThroughChainUnchanged() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/some/other/path"); + MockHttpServletResponse res = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + new SpaRoutingFilter().doFilter(req, res, chain); + assertNull("non-/ui request must not be forwarded", res.getForwardedUrl()); + assertNotNull("non-/ui request must be passed through the chain", chain.getRequest()); + } +} diff --git a/opennms-webapp/pom.xml b/opennms-webapp/pom.xml index ad99d9fe848c..87704b55a955 100644 --- a/opennms-webapp/pom.xml +++ b/opennms-webapp/pom.xml @@ -50,10 +50,10 @@ org.opennms org.opennms.ui - ${project.version} + ${opennms.ui.version} dist true - target/opennms-webapp-${project.version}/ui + target/opennms-webapp-${project.version}/ui-versions/default META-INF/** diff --git a/pom.xml b/pom.xml index 4a8f7621e67d..c743b297893f 100644 --- a/pom.xml +++ b/pom.xml @@ -2024,6 +2024,8 @@ 1.14.2 v22.22.2 10.24.0 + + ${project.version} 2.3.1