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