This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 1eaefbf61a [Fix][Zeta] Restore the Web UI route when the URL fragment 
is percent-encoded (#12775)
1eaefbf61a is described below

commit 1eaefbf61aa4820740750fedf830620f9a59ea0a
Author: Steven Zhao <[email protected]>
AuthorDate: Fri Oct 16 17:31:01 2026 +0000

    [Fix][Zeta] Restore the Web UI route when the URL fragment is 
percent-encoded (#12775)
---
 .gitignore                                         |   3 +
 docs/en/engines/zeta/rest-api-v2.md                |   1 +
 docs/en/engines/zeta/web-ui.md                     |  11 +
 docs/zh/engines/zeta/rest-api-v2.md                |   1 +
 docs/zh/engines/zeta/web-ui.md                     |   9 +
 .../seatunnel/engine/e2e/WebUiEntryPointIT.java    | 230 +++++++++++++++++++++
 .../seatunnel/engine/server/JettyService.java      |   8 +
 .../rest/filter/WebUiFragmentRedirectFilter.java   | 208 +++++++++++++++++++
 .../engine/server/rest/RestApiHttpBasicTest.java   |  36 ++++
 .../filter/WebUiFragmentRedirectFilterTest.java    | 128 ++++++++++++
 .../src/test/resources/ui/index.html               |  35 ++++
 11 files changed, 670 insertions(+)

diff --git a/.gitignore b/.gitignore
index 6e54678864..144e768228 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,6 +73,9 @@ node/
 dist/
 
 seatunnel-engine/seatunnel-engine-server/**/ui/*
+# The engine server module keeps a placeholder UI in its test resources, so 
that unit tests can
+# exercise the Jetty wiring that only exists when the packaged Web UI is on 
the classpath.
+!seatunnel-engine/seatunnel-engine-server/src/test/resources/ui/index.html
 
 seatunnel-edge-agent/**/edge-agent.id
 seatunnel-edge-agent/**/tmp/
diff --git a/docs/en/engines/zeta/rest-api-v2.md 
b/docs/en/engines/zeta/rest-api-v2.md
index 51349c07ee..a56142ff51 100644
--- a/docs/en/engines/zeta/rest-api-v2.md
+++ b/docs/en/engines/zeta/rest-api-v2.md
@@ -82,6 +82,7 @@ seatunnel:
 - If HTTP and `enable-dynamic-port = true` are enabled, the actual listening 
port may not be 8080. Jetty chooses the first available port between `port` and 
`port + port-range`. Use the Jetty startup log `SeaTunnel REST service started 
on http port xxx` as the source of truth. `/logs` and `/loggers?scope=cluster` 
resolve and report each member's actual bound HTTP port. The configured `port` 
remains unchanged, including when members share an HTTP configuration object.
 - If `context-path = /seatunnel`, both the Web UI and REST endpoints move 
under that prefix. For example, the overview endpoint becomes 
`/seatunnel/overview`.
 - The Web UI static resources and REST endpoints share the same Jetty service. 
If Jetty does not start, both are unavailable together.
+- If the address bar shows a Web UI route such as `/#/overview` but the page 
is the Jetty `HTTP ERROR 404 Not Found` page with `URI: /%23/overview`, the 
client sent the URL fragment percent-encoded. A node that serves the Web UI 
answers such a `GET` or `HEAD` request with `302` and `Location: 
<context-path>/#<route>`, so the browser lands on the route it meant to open. 
`curl -i --path-as-is 'http://<host>:8080/%23/overview'` shows that redirect, 
and when `context-path` is configured the  [...]
 
 ## Enable HTTPS
 
diff --git a/docs/en/engines/zeta/web-ui.md b/docs/en/engines/zeta/web-ui.md
index 8f39fabad9..c60a66d9da 100644
--- a/docs/en/engines/zeta/web-ui.md
+++ b/docs/en/engines/zeta/web-ui.md
@@ -30,6 +30,17 @@ If `context-path` is configured, include it before the hash 
route:
 http://<host>:8080/<context-path>/#/overview
 ```
 
+The route lives in the URL fragment, so it normally stays on the client. Some 
clients percent-encode
+the whole URL instead and send the fragment as part of the path - Safari does 
that when it
+re-requests a hash route, as do chat clients, wiki pages, and markdown 
previews - which turns
+`/#/overview` into `/%23/overview`. SeaTunnel answers such a `GET` or `HEAD` 
request with `302` and
+`Location: <context-path>/#<route>`, so the Web UI still loads. A path without 
a fragment is a REST
+API path instead: `GET /overview` returns cluster JSON, not the UI.
+
+The redirect is built from the configured `context-path`, so set 
`context-path` when the Web UI is
+exposed under a prefix. A prefix that a reverse proxy strips before forwarding 
the request is not
+part of the redirect target.
+
 ## Overview
 
 The Web UI of Apache SeaTunnel is a visual inspection console for SeaTunnel 
Engine. It helps operators view cluster overview data, running and finished 
jobs, job detail pages, logs, realtime DAG metrics, and the status of worker 
and master nodes.
diff --git a/docs/zh/engines/zeta/rest-api-v2.md 
b/docs/zh/engines/zeta/rest-api-v2.md
index afb5753047..033d1714fa 100644
--- a/docs/zh/engines/zeta/rest-api-v2.md
+++ b/docs/zh/engines/zeta/rest-api-v2.md
@@ -77,6 +77,7 @@ seatunnel:
 - 如果同时开启 HTTP 和 `enable-dynamic-port = true`,实际监听端口可能不是 8080,而是 `port` 到 `port 
+ port-range` 之间的第一个空闲端口。以 Jetty 启动日志 `SeaTunnel REST service started on http 
port xxx` 为准。`/logs` 和 `/loggers?scope=cluster` 会解析并报告各节点实际绑定的 HTTP 端口。配置中的 
`port` 保持不变,即使多个节点共享同一个 HTTP 配置对象也不例外。
 - 如果配置了 `context-path = /seatunnel`,Web UI 首页和 REST 路径都会整体前移,例如概览接口会变成 
`/seatunnel/overview`。
 - Web UI 静态资源和 REST API 共用同一个 Jetty 服务。只要 Jetty 没启动,两者都会一起不可用。
+- 如果地址栏显示的是 `/#/overview` 这类 Web UI 路由,但页面是 Jetty 的 `HTTP ERROR 404 Not 
Found`,且其中 `URI: /%23/overview`,说明客户端把 URL fragment 做了百分号编码后当路径发送。只要该节点带有 Web 
UI,SeaTunnel 就会对这类 `GET` 或 `HEAD` 请求返回 `302` 以及 `Location: 
<context-path>/#<route>`,浏览器会落到它本意要打开的路由。可用 `curl -i --path-as-is 
'http://<host>:8080/%23/overview'` 观察该重定向;配置了 `context-path` 时,重定向会保留该前缀。
 
 ## 开启 HTTPS
 
diff --git a/docs/zh/engines/zeta/web-ui.md b/docs/zh/engines/zeta/web-ui.md
index 4d1cb43699..88c2079163 100644
--- a/docs/zh/engines/zeta/web-ui.md
+++ b/docs/zh/engines/zeta/web-ui.md
@@ -30,6 +30,15 @@ http://<host>:8080/#/overview
 http://<host>:8080/<context-path>/#/overview
 ```
 
+路由存放在 URL fragment 中,正常情况下只保留在客户端。但有些客户端会对整条 URL 做百分号编码,
+把 fragment 当作路径的一部分发送——Safari 重新请求 hash 路由时就是这样,聊天工具、wiki 页面和
+Markdown 预览也有同样行为——`/#/overview` 于是变成 `/%23/overview`。对于这类 `GET` 或 `HEAD`
+请求,SeaTunnel 会返回 `302` 以及 `Location: <context-path>/#<route>`,Web UI 依然可以正常打开。
+不带 fragment 的路径走的是 REST API,例如 `GET /overview` 返回的是集群 JSON 而不是 UI。
+
+重定向地址是根据配置的 `context-path` 生成的,因此当 Web UI 通过某个前缀对外暴露时,请配置
+`context-path`;反向代理在转发前剥掉的前缀不会体现在重定向地址里。
+
 ## 概述
 
 Apache SeaTunnel 的 Web UI 是 SeaTunnel Engine 
的可视化巡检控制台。它可以帮助运维人员查看集群概览、运行中和已完成作业、作业详情页、日志、实时 DAG 指标,以及 worker 和 master 节点状态。
diff --git 
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/WebUiEntryPointIT.java
 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/WebUiEntryPointIT.java
new file mode 100644
index 0000000000..4740dec5ad
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/WebUiEntryPointIT.java
@@ -0,0 +1,230 @@
+/*
+ * 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.seatunnel.engine.e2e;
+
+import org.apache.seatunnel.engine.common.config.ConfigProvider;
+import org.apache.seatunnel.engine.common.config.SeaTunnelConfig;
+import org.apache.seatunnel.engine.server.SeaTunnelServerStarter;
+import org.apache.seatunnel.engine.server.rest.RestConstant;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.hazelcast.instance.impl.HazelcastInstanceImpl;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.notNullValue;
+
+/**
+ * E2E test for the Web UI entry point that the Zeta HTTP service serves.
+ *
+ * <p>The Web UI keeps its route in the URL fragment, so a deep link looks 
like {@code
+ * http://localhost:8080/#/overview}. A client that percent-encodes a whole 
URL - a link opened from
+ * a chat, a wiki or a markdown preview does that - requests {@code 
/%23/overview} instead, and the
+ * HTTP service used to answer with a bare Jetty "HTTP ERROR 404" page. The 
cases below pin down
+ * that the encoded fragment is restored by a redirect, that following it 
opens the Web UI, and that
+ * REST paths are not affected.
+ *
+ * <p>The Web UI assertions need the packaged UI resources, so this test 
belongs to the engine E2E
+ * lane that builds the UI module.
+ */
+public class WebUiEntryPointIT {
+
+    private static final String UI_ENTRY_PAGE_TITLE = "<title>Seatunnel Engine 
UI</title>";
+
+    /** The reported URL, {@code /#/overview}, as a client that 
percent-encoded it sends it. */
+    private static final String ENCODED_OVERVIEW_ROUTE = "/%23/overview";
+
+    private static HazelcastInstanceImpl node;
+    private static String baseUrl;
+    private static HttpClient client;
+
+    @BeforeAll
+    static void startEngine() throws IOException {
+        Assertions.assertNotNull(
+                WebUiEntryPointIT.class.getClassLoader().getResource("ui"),
+                "This test needs the packaged Web UI resources; run it without 
-Dskip.ui=true");
+        int httpPort = getAvailablePort();
+        node = startNode(httpPort, "");
+        baseUrl = "http://localhost:"; + httpPort;
+        // A fragment the client encoded is sent as a path, so the raw URL has 
to reach the wire
+        // exactly as written here; Redirect.NEVER keeps the redirect itself 
observable.
+        client = httpClient(HttpClient.Redirect.NEVER);
+        Awaitility.await()
+                .atMost(2, TimeUnit.MINUTES)
+                .until(() -> get(baseUrl, 
RestConstant.REST_URL_OVERVIEW).statusCode() == 200);
+    }
+
+    @AfterAll
+    static void stopEngine() {
+        if (node != null) {
+            node.shutdown();
+            node = null;
+        }
+    }
+
+    @Test
+    void testEncodedFragmentIsRedirectedToTheWebUiRoute() throws Exception {
+        HttpResponse<String> response = get(baseUrl, ENCODED_OVERVIEW_ROUTE);
+
+        Assertions.assertEquals(302, response.statusCode());
+        Assertions.assertEquals(
+                "/#/overview",
+                response.headers().firstValue("Location").orElse(null),
+                "The percent-encoded fragment must come back as a URL 
fragment");
+    }
+
+    @Test
+    void testEncodedFragmentDeepLinkKeepsItsRoute() throws Exception {
+        HttpResponse<String> response = get(baseUrl, "/%23/jobs/1001");
+
+        Assertions.assertEquals(302, response.statusCode());
+        Assertions.assertEquals(
+                "/#/jobs/1001", 
response.headers().firstValue("Location").orElse(null));
+    }
+
+    @Test
+    void testFollowingTheRedirectOpensTheWebUi() throws Exception {
+        HttpResponse<String> response =
+                httpClient(HttpClient.Redirect.NORMAL)
+                        .send(
+                                request(baseUrl, ENCODED_OVERVIEW_ROUTE),
+                                HttpResponse.BodyHandlers.ofString());
+
+        Assertions.assertEquals(200, response.statusCode());
+        Assertions.assertTrue(
+                
response.headers().firstValue("Content-Type").orElse("").contains("text/html"),
+                "Following the redirect must serve the Web UI entry page");
+        Assertions.assertTrue(
+                response.body().contains(UI_ENTRY_PAGE_TITLE),
+                "Following the redirect must serve the Web UI entry page");
+    }
+
+    @Test
+    void testWebUiIsServedAtTheRootPath() throws Exception {
+        HttpResponse<String> response = get(baseUrl, "/");
+
+        Assertions.assertEquals(200, response.statusCode());
+        Assertions.assertTrue(response.body().contains(UI_ENTRY_PAGE_TITLE));
+    }
+
+    @Test
+    void testRestPathIsNotRedirectedToTheWebUi() {
+        given().get(baseUrl + RestConstant.REST_URL_OVERVIEW)
+                .then()
+                .statusCode(200)
+                .body("projectVersion", notNullValue());
+    }
+
+    @Test
+    void testUnknownPathIsNotRedirectedToTheWebUi() throws Exception {
+        Assertions.assertEquals(404, get(baseUrl, 
"/not-a-web-ui-route").statusCode());
+    }
+
+    /**
+     * A configured {@code context-path} moves the Web UI route and the REST 
endpoints under the
+     * prefix, and the restored fragment has to keep that prefix. This needs 
its own node, so it is
+     * the one case that does not use the node from {@link #startEngine()}.
+     */
+    @Test
+    void testEncodedFragmentRedirectKeepsTheConfiguredContextPath() throws 
Exception {
+        int httpPort = getAvailablePort();
+        String prefixedBaseUrl = "http://localhost:"; + httpPort;
+        String contextPath = "/seatunnel";
+        HazelcastInstanceImpl prefixedNode = null;
+        try {
+            prefixedNode = startNode(httpPort, contextPath);
+            Awaitility.await()
+                    .atMost(2, TimeUnit.MINUTES)
+                    .until(
+                            () ->
+                                    get(
+                                                            prefixedBaseUrl,
+                                                            contextPath
+                                                                    + 
RestConstant
+                                                                            
.REST_URL_OVERVIEW)
+                                                    .statusCode()
+                                            == 200);
+
+            HttpResponse<String> response =
+                    client.send(
+                            request(prefixedBaseUrl, contextPath + 
ENCODED_OVERVIEW_ROUTE),
+                            HttpResponse.BodyHandlers.ofString());
+
+            Assertions.assertEquals(302, response.statusCode());
+            Assertions.assertEquals(
+                    contextPath + "/#/overview",
+                    response.headers().firstValue("Location").orElse(null));
+        } finally {
+            if (prefixedNode != null) {
+                prefixedNode.shutdown();
+            }
+        }
+    }
+
+    private static HttpResponse<String> get(String targetBaseUrl, String path) 
throws Exception {
+        return client.send(request(targetBaseUrl, path), 
HttpResponse.BodyHandlers.ofString());
+    }
+
+    private static HttpRequest request(String targetBaseUrl, String path) {
+        return HttpRequest.newBuilder(URI.create(targetBaseUrl + 
path)).GET().build();
+    }
+
+    private static HazelcastInstanceImpl startNode(int httpPort, String 
contextPath) {
+        SeaTunnelConfig config = ConfigProvider.locateAndGetSeaTunnelConfig();
+        // Each node needs its own cluster: nodes in one JVM and with the same 
name would join each
+        // other over TCP-IP discovery, and the redirect assertions do not 
need that.
+        config.getHazelcastConfig()
+                .setClusterName(
+                        TestUtils.getClusterName(
+                                "WebUiEntryPointIT" + contextPath.replace('/', 
'_')));
+        config.getEngineConfig().getHttpConfig().setEnabled(true);
+        config.getEngineConfig().getHttpConfig().setEnableDynamicPort(false);
+        config.getEngineConfig().getHttpConfig().setPort(httpPort);
+        // Other ITs in the same JVM may leave another context path behind.
+        config.getEngineConfig().getHttpConfig().setContextPath(contextPath);
+        return SeaTunnelServerStarter.createHazelcastInstance(config);
+    }
+
+    private static HttpClient httpClient(HttpClient.Redirect redirectPolicy) {
+        return HttpClient.newBuilder()
+                .followRedirects(redirectPolicy)
+                .connectTimeout(Duration.ofSeconds(10))
+                .build();
+    }
+
+    private static int getAvailablePort() {
+        try (ServerSocket socket = new ServerSocket(0)) {
+            return socket.getLocalPort();
+        } catch (IOException e) {
+            throw new IllegalStateException("No available port for the Web UI 
entry point IT", e);
+        }
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
index 02d51290b5..46cff4b24d 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
@@ -29,6 +29,7 @@ import 
org.apache.seatunnel.engine.common.config.SeaTunnelConfig;
 import org.apache.seatunnel.engine.common.config.server.HttpConfig;
 import org.apache.seatunnel.engine.server.rest.filter.BasicAuthFilter;
 import org.apache.seatunnel.engine.server.rest.filter.ExceptionHandlingFilter;
+import 
org.apache.seatunnel.engine.server.rest.filter.WebUiFragmentRedirectFilter;
 import org.apache.seatunnel.engine.server.rest.servlet.AllLogNameServlet;
 import org.apache.seatunnel.engine.server.rest.servlet.AllNodeLogServlet;
 import 
org.apache.seatunnel.engine.server.rest.servlet.CheckpointHistoryServlet;
@@ -199,6 +200,13 @@ public class JettyService {
         URL uiResource = JettyService.class.getClassLoader().getResource("ui");
         if (uiResource != null) {
             defaultServlet.setInitParameter("resourceBase", 
uiResource.toExternalForm());
+            // The Web UI route lives in the URL fragment. Clients that escape 
a whole URL send that
+            // fragment as part of the path, which matches no static resource, 
so restore it instead
+            // of answering with the Jetty 404 page. Only a node that serves 
the UI needs this.
+            FilterHolder webUiFragmentRedirectHolder =
+                    new FilterHolder(new WebUiFragmentRedirectFilter());
+            context.addFilter(
+                    webUiFragmentRedirectHolder, "/*", 
EnumSet.of(DispatcherType.REQUEST));
         } else {
             log.warn("UI resources not found in classpath");
         }
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/filter/WebUiFragmentRedirectFilter.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/filter/WebUiFragmentRedirectFilter.java
new file mode 100644
index 0000000000..1cf2df2553
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/filter/WebUiFragmentRedirectFilter.java
@@ -0,0 +1,208 @@
+/*
+ * 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.seatunnel.engine.server.rest.filter;
+
+import org.apache.seatunnel.shade.org.apache.commons.lang3.StringUtils;
+
+import lombok.extern.slf4j.Slf4j;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+
+/**
+ * Restores the URL fragment of Web UI routes that a client percent-encoded 
into the request path.
+ *
+ * <p>The Web UI is a single page application that keeps its route in the URL 
fragment, so a deep
+ * link looks like {@code http://host:8080/#/overview}. A client that escapes 
the whole URL sends
+ * the fragment as part of the path instead ({@code /%23/overview}): Safari 
does that when it
+ * re-requests a hash route, and so do tools that escape a whole URL, such as 
a chat client, a wiki
+ * page or a markdown preview. No static resource matches that path, so the 
Web UI answered with the
+ * bare Jetty "HTTP ERROR 404" page while the address bar still showed {@code 
#/overview}. This
+ * filter recognizes that shape and redirects the client to the URL it meant 
to open, which the Web
+ * UI router resolves as usual.
+ *
+ * <p>A fragment sent unencoded never reaches the server (the client keeps 
it), so only the
+ * percent-encoded form is handled here. The route and the query are only 
re-emitted when they are
+ * well-formed URI text; a request that carries anything else is left to the 
normal handling, so the
+ * {@code Location} value never holds bytes the client did not send as a URI. 
Every other request
+ * passes through untouched.
+ */
+@Slf4j
+public class WebUiFragmentRedirectFilter implements Filter {
+
+    /** A percent-encoded {@code #}, as produced by clients that escape a 
whole URL. */
+    private static final String ENCODED_FRAGMENT_PATH_PREFIX = "/%23";
+
+    /** URI characters a hash route or its query may carry without escaping. */
+    private static final String ROUTE_CHARACTERS = "-._~!$&'()*+,;=:@/?";
+
+    private static final String GET = "GET";
+
+    private static final String HEAD = "HEAD";
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+        // No initialization needed
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, 
FilterChain chain)
+            throws IOException, ServletException {
+        if (!(request instanceof HttpServletRequest)
+                || !(response instanceof HttpServletResponse)) {
+            chain.doFilter(request, response);
+            return;
+        }
+
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
+        String location =
+                redirectLocationFor(
+                        httpRequest.getMethod(),
+                        httpRequest.getRequestURI(),
+                        httpRequest.getContextPath(),
+                        httpRequest.getQueryString());
+        if (location == null) {
+            chain.doFilter(request, response);
+            return;
+        }
+
+        log.debug(
+                "Redirecting {} to {} to restore the Web UI route the client 
encoded",
+                httpRequest.getRequestURI(),
+                location);
+        HttpServletResponse httpResponse = (HttpServletResponse) response;
+        httpResponse.setStatus(HttpServletResponse.SC_FOUND);
+        httpResponse.setHeader("Location", location);
+    }
+
+    @Override
+    public void destroy() {
+        // No resources to release
+    }
+
+    /**
+     * Builds the Web UI URL a request meant to open when its fragment arrived 
percent-encoded.
+     *
+     * @param method the HTTP method of the request
+     * @param requestUri the raw, still percent-encoded request URI
+     * @param contextPath the context path the request was served under, empty 
when there is none
+     * @param queryString the query string of the request, {@code null} when 
there is none
+     * @return the location to redirect to, or {@code null} when the request 
carries no encoded
+     *     fragment and must be handled normally
+     */
+    static String redirectLocationFor(
+            String method, String requestUri, String contextPath, String 
queryString) {
+        if (!GET.equals(method) && !HEAD.equals(method)) {
+            return null;
+        }
+        if (requestUri == null || contextPath == null || 
!requestUri.startsWith(contextPath)) {
+            return null;
+        }
+        String pathInContext = requestUri.substring(contextPath.length());
+        if (!pathInContext.startsWith(ENCODED_FRAGMENT_PATH_PREFIX)) {
+            return null;
+        }
+
+        String fragment = 
pathInContext.substring(ENCODED_FRAGMENT_PATH_PREFIX.length());
+        if (!isUriText(fragment) || !isUriText(queryString)) {
+            log.debug(
+                    "Not redirecting {}: the encoded fragment is not a URI 
reference", requestUri);
+            return null;
+        }
+
+        StringBuilder location = new StringBuilder(contextPath).append('/');
+        if (!fragment.isEmpty()) {
+            // The client kept the route after the encoded separator, so it is 
the fragment again.
+            location.append('#').append(fragment);
+        }
+        if (StringUtils.isNotBlank(queryString)) {
+            location.append('?').append(queryString);
+        }
+        return location.toString();
+    }
+
+    /**
+     * Checks that a route fragment or a query string is well-formed URI text: 
URI characters, with
+     * {@code %} only as part of a {@code %HH} escape that is neither a 
control character nor {@code
+     * DEL}.
+     *
+     * <p>A hash route never needs anything else, so a raw {@code <}, an 
encoded line break or a
+     * truncated escape means the caller did not send a Web UI URL and the 
request is answered
+     * normally. Keeping this check here means the header value is well formed 
regardless of what
+     * the HTTP layer accepted on the request line.
+     *
+     * @param text the raw fragment or query of the request, {@code null} when 
there is none
+     * @return {@code true} when the text may be re-emitted in a {@code 
Location} header
+     */
+    private static boolean isUriText(String text) {
+        if (text == null) {
+            return true;
+        }
+        for (int i = 0; i < text.length(); i++) {
+            char character = text.charAt(i);
+            if (character == '%') {
+                // A truncated or non-hex escape cannot be part of a URI 
reference.
+                if (i + 2 >= text.length()) {
+                    return false;
+                }
+                int high = hexValue(text.charAt(i + 1));
+                int low = hexValue(text.charAt(i + 2));
+                if (high < 0 || low < 0) {
+                    return false;
+                }
+                int escaped = (high << 4) + low;
+                if (escaped < 0x20 || escaped == 0x7F) {
+                    return false;
+                }
+                i += 2;
+            } else if (!isRouteCharacter(character)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** @return the value of an ASCII hex digit, or {@code -1} for anything 
else */
+    private static int hexValue(char character) {
+        if (character >= '0' && character <= '9') {
+            return character - '0';
+        }
+        if (character >= 'a' && character <= 'f') {
+            return character - 'a' + 10;
+        }
+        if (character >= 'A' && character <= 'F') {
+            return character - 'A' + 10;
+        }
+        return -1;
+    }
+
+    private static boolean isRouteCharacter(char character) {
+        return (character >= 'a' && character <= 'z')
+                || (character >= 'A' && character <= 'Z')
+                || (character >= '0' && character <= '9')
+                || ROUTE_CHARACTERS.indexOf(character) >= 0;
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/RestApiHttpBasicTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/RestApiHttpBasicTest.java
index 235ae0278e..8206bb14b9 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/RestApiHttpBasicTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/RestApiHttpBasicTest.java
@@ -146,6 +146,42 @@ class RestApiHttpBasicTest extends 
AbstractSeaTunnelServerTest {
         testLogRestApiResponse("JSON");
     }
 
+    /**
+     * The Web UI fragment redirect has to stay behind authentication: an 
unauthenticated request
+     * for a percent-encoded hash route answers 401, and only an authenticated 
one is redirected to
+     * the route the client meant to open.
+     */
+    @Test
+    void testFragmentRedirectRequiresAuthentication() throws IOException {
+        HttpURLConnection unauthenticated = null;
+        HttpURLConnection authenticated = null;
+        try {
+            unauthenticated = openEncodedFragmentRoute();
+            Assertions.assertEquals(401, unauthenticated.getResponseCode());
+
+            authenticated = openEncodedFragmentRoute();
+            setBasicAuth(authenticated);
+            Assertions.assertEquals(302, authenticated.getResponseCode());
+            Assertions.assertEquals("/#/overview", 
authenticated.getHeaderField("Location"));
+        } finally {
+            if (unauthenticated != null) {
+                unauthenticated.disconnect();
+            }
+            if (authenticated != null) {
+                authenticated.disconnect();
+            }
+        }
+    }
+
+    private HttpURLConnection openEncodedFragmentRoute() throws IOException {
+        // A client that escaped the whole URL sends the fragment as part of 
the path, so the
+        // redirect itself has to stay observable here instead of being 
followed.
+        HttpURLConnection connection =
+                (HttpURLConnection) new URL(DOMAIN + 
"/%23/overview").openConnection();
+        connection.setInstanceFollowRedirects(false);
+        return connection;
+    }
+
     public void setBasicAuth(HttpURLConnection connection) {
         // Basic Auth
         Encoder encoder = Base64.getEncoder();
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/filter/WebUiFragmentRedirectFilterTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/filter/WebUiFragmentRedirectFilterTest.java
new file mode 100644
index 0000000000..d6adf22e97
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/filter/WebUiFragmentRedirectFilterTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.seatunnel.engine.server.rest.filter;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The Web UI route lives in the URL fragment, so a client that 
percent-encoded a whole URL sends it
+ * as part of the request path and no static resource matches it. The mapping 
below has to restore
+ * the route for that shape only; every other request must stay untouched.
+ */
+class WebUiFragmentRedirectFilterTest {
+
+    @Test
+    void shouldMapEncodedFragmentToTheWebUiRoute() {
+        Assertions.assertEquals(
+                "/#/overview",
+                WebUiFragmentRedirectFilter.redirectLocationFor("GET", 
"/%23/overview", "", null));
+    }
+
+    @Test
+    void shouldKeepTheDeepLinkRouteAndItsQueryString() {
+        Assertions.assertEquals(
+                "/#/jobs/1001?tab=metrics",
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/jobs/1001", "", "tab=metrics"));
+    }
+
+    @Test
+    void shouldKeepTheConfiguredContextPath() {
+        Assertions.assertEquals(
+                "/seatunnel/#/overview",
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/seatunnel/%23/overview", "/seatunnel", null));
+    }
+
+    @Test
+    void shouldKeepAnEscapedRouteAndItsQuery() {
+        Assertions.assertEquals(
+                "/#/%E4%B8%AD%E6%96%87/jobs?tab=metrics&pretty=true",
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/%E4%B8%AD%E6%96%87/jobs", "", 
"tab=metrics&pretty=true"));
+        Assertions.assertEquals(
+                "/#/overview?from=chat%20client",
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/overview", "", "from=chat%20client"));
+    }
+
+    @Test
+    void shouldRefuseRoutesThatAreNotUriReferences() {
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/<script>alert(1)</script>", "", null));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/overview", "", 
"x=%0d%0aInjected:%20yes"));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/overview", "", "x=100%"));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/overview", "", "x=%2"));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/overview", "", "x=%zz"));
+        // Non-ASCII digits are digits for Character.digit, but not for a URI 
escape.
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/overview", "", "x=%\u0663\u0663"));
+    }
+
+    @Test
+    void shouldMapAnEncodedFragmentWithoutRouteToTheWebUiRoot() {
+        Assertions.assertEquals(
+                "/", WebUiFragmentRedirectFilter.redirectLocationFor("GET", 
"/%23", "", null));
+        Assertions.assertEquals(
+                "/?from=chat",
+                WebUiFragmentRedirectFilter.redirectLocationFor("GET", "/%23", 
"", "from=chat"));
+    }
+
+    @Test
+    void shouldRedirectHeadButNotRequestsWithABody() {
+        Assertions.assertEquals(
+                "/#/overview",
+                WebUiFragmentRedirectFilter.redirectLocationFor("HEAD", 
"/%23/overview", "", null));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor("POST", 
"/%23/overview", "", null));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "DELETE", "/%23/overview", "", null));
+    }
+
+    @Test
+    void shouldLeaveRegularWebUiAndRestPathsAlone() {
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor("GET", "/", 
"", null));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor("GET", 
"/index.html", "", null));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor("GET", 
"/overview", "", null));
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/jobs/checkpoints", "", "jobId=1"));
+    }
+
+    @Test
+    void shouldNotRedirectEncodedFragmentOutsideTheContextPath() {
+        Assertions.assertNull(
+                WebUiFragmentRedirectFilter.redirectLocationFor(
+                        "GET", "/%23/overview", "/seatunnel", null));
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/resources/ui/index.html 
b/seatunnel-engine/seatunnel-engine-server/src/test/resources/ui/index.html
new file mode 100644
index 0000000000..6e156c804f
--- /dev/null
+++ b/seatunnel-engine/seatunnel-engine-server/src/test/resources/ui/index.html
@@ -0,0 +1,35 @@
+<!--
+ * 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.
+-->
+
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8">
+  <title>Seatunnel Engine UI</title>
+</head>
+
+<body>
+  <!--
+    Placeholder for the packaged Web UI. JettyService only registers the 
fragment redirect filter
+    when the UI resources are on the classpath, and unit test lanes run 
without the UI build, so
+    this file keeps that wiring available for the REST tests that assert the 
redirect.
+  -->
+  <div id="app"></div>
+</body>
+
+</html>

Reply via email to