From 25666ba915718bcdcfd11638f47c464fe06ead87 Mon Sep 17 00:00:00 2001 From: Chance Newkirk Date: Tue, 28 Apr 2026 07:40:25 -0700 Subject: [PATCH 1/2] feat(ui): unpack SPA into ui-versions/default and add opennms.ui.version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webapp assembly now unpacks the SPA artifact to target/.../ui-versions/default/ instead of target/.../ui/. A new opennms.ui.version Maven property (default ${project.version}) lets the build target a different SPA artifact version without rebuilding the backend. The /ui/ filesystem path is no longer materialized at build time. The SpaRoutingFilter (separate commit) forwards /ui/* requests to /ui-versions//* at request time, which works on Windows as well as Linux — no symlinks anywhere. --- opennms-webapp/pom.xml | 4 ++-- pom.xml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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 From 033081dfd45f0883c02d7e33279102b2978fdcdc Mon Sep 17 00:00:00 2001 From: Chance Newkirk Date: Mon, 27 Apr 2026 19:45:28 -0700 Subject: [PATCH 2/2] feat(ui): cross-platform /ui version routing via servlet filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpaRoutingFilter now reads org.opennms.web.ui.version (default "default") and forwards /ui/* requests to /ui-versions//* via Jetty's RequestDispatcher.forward() — pure Java, no symlinks. Deep links fall back to /index.html for SPA history mode; assets keep their relative path. Version names matching [A-Za-z0-9._-]+ minus ".." are accepted; anything else falls back to "default". Replaces the symlink-based ui -> ui-versions/default mechanism so the mechanism works on Windows as well as Linux. Includes 8 unit tests covering default + named versions, asset/SPA/SVG paths, traversal rejection, and non-/ui passthrough. --- .../opennms/web/servlet/SpaRoutingFilter.java | 37 +++--- .../web/servlet/SpaRoutingFilterTest.java | 109 ++++++++++++++++++ 2 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 opennms-web-api/src/test/java/org/opennms/web/servlet/SpaRoutingFilterTest.java 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()); + } +}