This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 5e8b20fa9e Issue #8297 : Serve explorer HTML and PDF with a document
base on Hop Web (#8306)
5e8b20fa9e is described below
commit 5e8b20fa9ec5727a9f626b65b1321ff23991d195
Author: Matt Casters <[email protected]>
AuthorDate: Sun Sep 13 17:11:21 2026 +0200
Issue #8297 : Serve explorer HTML and PDF with a document base on Hop Web
(#8306)
* Issue #8297 : Serve explorer HTML and PDF with a document base on Hop Web
Opening .html/.htm in the File Explorer on Hop Web used Browser.setText(),
so the RAP iframe had no document URL and relative CSS, images, and links
404'd against /ui. PDFs used a server-side file: URL the browser cannot
fetch.
Serve allow-listed files at /explorer-file/{token}/{relative-path} through
HopVfs in the RAP UISession so the iframe has a directory base. Desktop
uses file:/http(s) setUrl. Paths are sandboxed to the explorer root.
* Issue #8297 : Address review feedback on explorer file serving
- Add Content-Security-Policy header ('connect-src \'none\'; form-action
\'none\'; base-uri \'none\'') to prevent script escalation while preserving
page title evaluation
- Dynamically derive HTTP session ID from UISession in ExplorerFileLease so
rotated HTTP sessions remain valid
- Treat request path info as already decoded in ExplorerFileServing and
escape '%' as '%25' when resolving in HopVfs
- Anchor scheme check to scheme prefix on the first segment in
sanitizeRelativePath to allow valid filenames containing colons like
report:2026-09-11.html
* Issue #8297 : Decode VFS relative names before sanitizing explorer paths
getRelativeName() leaves '%' URI-escaped, which violated the already-decoded
contract of sanitizeRelativePath and double-escaped names like 100%
done.html.
---
assemblies/web/src/main/resources/WEB-INF/web.xml | 10 +
docker/local-auth-config/web.xml | 10 +
.../transforms/types/ExplorerBrowserSupport.java | 80 ++++++
.../types/HtmlExplorerFileTypeHandler.java | 22 +-
.../types/PdfExplorerFileTypeHandler.java | 17 +-
.../types/ExplorerBrowserSupportTest.java | 44 +++
.../org/apache/hop/ui/hopgui/HopWebEntryPoint.java | 3 +
.../hop/ui/hopgui/explorer/ExplorerFileLease.java | 51 ++++
.../ui/hopgui/explorer/ExplorerFileRegistry.java | 72 +++++
.../ui/hopgui/explorer/ExplorerFileServlet.java | 172 ++++++++++++
.../ui/hopgui/explorer/RapExplorerFileService.java | 74 +++++
.../hopgui/explorer/ExplorerFileServletTest.java | 259 ++++++++++++++++++
.../explorer/web/ExplorerFileServing.java | 304 +++++++++++++++++++++
.../explorer/web/HopWebExplorerFileHelper.java | 50 ++++
.../explorer/web/IHopWebExplorerFileService.java | 37 +++
.../explorer/web/ExplorerFileServingTest.java | 269 ++++++++++++++++++
.../explorer/web/HopWebExplorerFileHelperTest.java | 46 ++++
17 files changed, 1494 insertions(+), 26 deletions(-)
diff --git a/assemblies/web/src/main/resources/WEB-INF/web.xml
b/assemblies/web/src/main/resources/WEB-INF/web.xml
index 9d89378e31..5e1cff2464 100644
--- a/assemblies/web/src/main/resources/WEB-INF/web.xml
+++ b/assemblies/web/src/main/resources/WEB-INF/web.xml
@@ -76,6 +76,16 @@
<url-pattern>/ui-dark</url-pattern>
</servlet-mapping>
+ <!-- Path-shaped explorer file URLs so HTML in the RAP Browser has a
directory base. -->
+ <servlet>
+ <servlet-name>ExplorerFile</servlet-name>
+
<servlet-class>org.apache.hop.ui.hopgui.explorer.ExplorerFileServlet</servlet-class>
+ </servlet>
+ <servlet-mapping>
+ <servlet-name>ExplorerFile</servlet-name>
+ <url-pattern>/explorer-file/*</url-pattern>
+ </servlet-mapping>
+
<servlet>
<servlet-name>welcome</servlet-name>
<jsp-file>/docs/English/welcome/index.html</jsp-file>
diff --git a/docker/local-auth-config/web.xml b/docker/local-auth-config/web.xml
index cb66c81428..6200ecfb64 100644
--- a/docker/local-auth-config/web.xml
+++ b/docker/local-auth-config/web.xml
@@ -73,6 +73,16 @@
<url-pattern>/ui-dark</url-pattern>
</servlet-mapping>
+ <!-- Path-shaped explorer file URLs so HTML in the RAP Browser has a
directory base. -->
+ <servlet>
+ <servlet-name>ExplorerFile</servlet-name>
+
<servlet-class>org.apache.hop.ui.hopgui.explorer.ExplorerFileServlet</servlet-class>
+ </servlet>
+ <servlet-mapping>
+ <servlet-name>ExplorerFile</servlet-name>
+ <url-pattern>/explorer-file/*</url-pattern>
+ </servlet-mapping>
+
<servlet>
<servlet-name>welcome</servlet-name>
<jsp-file>/docs/English/welcome/index.html</jsp-file>
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ExplorerBrowserSupport.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ExplorerBrowserSupport.java
new file mode 100644
index 0000000000..a7468a6b8f
--- /dev/null
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ExplorerBrowserSupport.java
@@ -0,0 +1,80 @@
+/*
+ * 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.hop.pipeline.transforms.types;
+
+import java.util.Locale;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import
org.apache.hop.ui.hopgui.perspective.explorer.web.HopWebExplorerFileHelper;
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.browser.Browser;
+
+/** Loads a file into an explorer {@link Browser} with a real document URL
when possible. */
+final class ExplorerBrowserSupport {
+
+ private ExplorerBrowserSupport() {}
+
+ static boolean isHttpUrl(String filename) {
+ if (filename == null) {
+ return false;
+ }
+ String lower = filename.toLowerCase(Locale.ROOT);
+ return lower.startsWith("http://") || lower.startsWith("https://");
+ }
+
+ /**
+ * {@code Browser.setUrl} when the filename is http(s), a Hop Web
explorer-file URL can be built,
+ * or the VFS URL is fetchable by a desktop browser ({@code file:}/{@code
http:}/{@code https:}).
+ *
+ * @return true when the browser was given a URL
+ */
+ static boolean loadInBrowser(Browser browser, String filename, IVariables
variables)
+ throws Exception {
+ if (browser == null || filename == null) {
+ return false;
+ }
+ if (isHttpUrl(filename)) {
+ browser.setUrl(filename);
+ return true;
+ }
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ String url = HopWebExplorerFileHelper.urlFor(filename, variables);
+ if (url != null) {
+ browser.setUrl(url);
+ return true;
+ }
+ return false;
+ }
+ FileObject fileObject = HopVfs.getFileObject(filename, variables);
+ String url = fileObject.getURL().toString();
+ if (isBrowserFetchable(url)) {
+ browser.setUrl(url);
+ return true;
+ }
+ return false;
+ }
+
+ static boolean isBrowserFetchable(String url) {
+ if (url == null) {
+ return false;
+ }
+ String lower = url.toLowerCase(Locale.ROOT);
+ return lower.startsWith("file:") || lower.startsWith("http:") ||
lower.startsWith("https:");
+ }
+}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/HtmlExplorerFileTypeHandler.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/HtmlExplorerFileTypeHandler.java
index 2e298759a8..e6a6a77824 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/HtmlExplorerFileTypeHandler.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/HtmlExplorerFileTypeHandler.java
@@ -152,26 +152,20 @@ public class HtmlExplorerFileTypeHandler extends
BaseExplorerFileTypeHandler {
public void reload() {
try {
String filename = explorerFile.getFilename();
- if (filename.toLowerCase().startsWith("http://")
- || filename.toLowerCase().startsWith("https://")) {
- wBrowser.setUrl(filename);
+ if (!ExplorerBrowserSupport.isHttpUrl(filename)) {
+ // Keep a copy for Save: the Browser widget does not expose edited
markup.
+ String htmlContent = readTextFileContent(StandardCharsets.UTF_8);
+ originalHtmlContent = Const.NVL(htmlContent, "");
+ }
- // Try to update the tab title after the page loads
- // This is done asynchronously since the page needs to load first
+ if (ExplorerBrowserSupport.loadInBrowser(wBrowser, filename,
hopGui.getVariables())) {
updateTitleFromPageTitle();
-
clearChanged();
return;
}
- // Read HTML content from file
- String htmlContent = readTextFileContent(StandardCharsets.UTF_8);
- originalHtmlContent = Const.NVL(htmlContent, "");
-
- // Display HTML in browser widget
- wBrowser.setText(originalHtmlContent);
-
- // Clear any change flags since we just reloaded
+ // Fallback when there is no fetchable document URL (non-file VFS on
desktop).
+ wBrowser.setText(Const.NVL(originalHtmlContent, ""));
clearChanged();
} catch (Exception e) {
LogChannel.UI.logError(
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PdfExplorerFileTypeHandler.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PdfExplorerFileTypeHandler.java
index e55ce630b8..beb77bf077 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PdfExplorerFileTypeHandler.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PdfExplorerFileTypeHandler.java
@@ -61,26 +61,19 @@ public class PdfExplorerFileTypeHandler extends
BaseExplorerFileTypeHandler {
@Override
public void reload() {
try {
- // Get the file URL and load it in the browser
- // The browser widget will use its built-in PDF viewer to display the PDF
- //
String filename = explorerFile.getFilename();
- // Check if file exists
if (!HopVfs.fileExists(filename)) {
showError("File not found: " + filename);
return;
}
- // Convert the filename to a file URL
- // For local files, we need to use the file:// protocol
- String fileUrl = HopVfs.getFileObject(filename).getURL().toString();
-
- // Set the URL in the browser widget
- wBrowser.setUrl(fileUrl);
+ if (ExplorerBrowserSupport.loadInBrowser(wBrowser, filename,
hopGui.getVariables())) {
+ clearChanged();
+ return;
+ }
- // Clear any change flags since we just reloaded
- clearChanged();
+ showError("Unable to display PDF file: " + filename);
} catch (Exception e) {
LogChannel.UI.logError("Error loading PDF file '" +
explorerFile.getFilename() + "'", e);
showError("Error loading PDF file: " + Const.NVL(e.getMessage(),
"Unknown error"));
diff --git
a/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/ExplorerBrowserSupportTest.java
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/ExplorerBrowserSupportTest.java
new file mode 100644
index 0000000000..7892c5acef
--- /dev/null
+++
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/ExplorerBrowserSupportTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.hop.pipeline.transforms.types;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class ExplorerBrowserSupportTest {
+
+ @Test
+ void httpUrls() {
+
assertTrue(ExplorerBrowserSupport.isHttpUrl("https://hop.apache.org/manual"));
+ assertTrue(ExplorerBrowserSupport.isHttpUrl("HTTP://example.com/a.html"));
+ assertFalse(ExplorerBrowserSupport.isHttpUrl("/project/docs/index.html"));
+ assertFalse(ExplorerBrowserSupport.isHttpUrl("file:///tmp/index.html"));
+ assertFalse(ExplorerBrowserSupport.isHttpUrl(null));
+ }
+
+ @Test
+ void browserFetchableSchemes() {
+
assertTrue(ExplorerBrowserSupport.isBrowserFetchable("file:///tmp/index.html"));
+
assertTrue(ExplorerBrowserSupport.isBrowserFetchable("https://example.com/a.html"));
+
assertTrue(ExplorerBrowserSupport.isBrowserFetchable("http://localhost/a.html"));
+
assertFalse(ExplorerBrowserSupport.isBrowserFetchable("s3://bucket/docs/index.html"));
+ assertFalse(ExplorerBrowserSupport.isBrowserFetchable(null));
+ }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index 780ed72302..a40e6ccb1d 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -36,8 +36,10 @@ import org.apache.hop.history.AuditManager;
import org.apache.hop.history.AuditState;
import org.apache.hop.ui.core.PropsUi;
import org.apache.hop.ui.hopgui.canvas.CanvasGraphRegistry;
+import org.apache.hop.ui.hopgui.explorer.RapExplorerFileService;
import org.apache.hop.ui.hopgui.file.shared.DrillDownGuiPlugin;
import org.apache.hop.ui.hopgui.notifications.NotificationService;
+import
org.apache.hop.ui.hopgui.perspective.explorer.web.HopWebExplorerFileHelper;
import org.eclipse.rap.rwt.RWT;
import org.eclipse.rap.rwt.application.AbstractEntryPoint;
import org.eclipse.rap.rwt.client.service.JavaScriptExecutor;
@@ -258,6 +260,7 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
ServerPushSessionFacade.start();
HopWebUrlHelper.setUrlUpdater(new RapHopWebUrlUpdater());
+ HopWebExplorerFileHelper.setService(new RapExplorerFileService());
// Persist open tabs when the session ends (browser close, timeout, etc.).
// We use the session-cached audit manager so no request is needed.
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileLease.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileLease.java
new file mode 100644
index 0000000000..90138187a4
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileLease.java
@@ -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.hop.ui.hopgui.explorer;
+
+import jakarta.servlet.http.HttpSession;
+import lombok.Getter;
+import lombok.Setter;
+import org.eclipse.rap.rwt.service.UISession;
+
+/** Session-scoped permission to serve explorer files under a single VFS root.
*/
+@Getter
+public final class ExplorerFileLease {
+
+ private final String token;
+ private final UISession uiSession;
+
+ @Setter private volatile String rootVfsUri;
+
+ ExplorerFileLease(String token, UISession uiSession, String rootVfsUri) {
+ this.token = token;
+ this.uiSession = uiSession;
+ this.rootVfsUri = rootVfsUri;
+ }
+
+ public String getHttpSessionId() {
+ if (uiSession == null) {
+ return null;
+ }
+ try {
+ HttpSession httpSession = uiSession.getHttpSession();
+ return httpSession != null ? httpSession.getId() : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileRegistry.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileRegistry.java
new file mode 100644
index 0000000000..4d10527e84
--- /dev/null
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileRegistry.java
@@ -0,0 +1,72 @@
+/*
+ * 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.hop.ui.hopgui.explorer;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import org.eclipse.rap.rwt.service.UISession;
+
+/** Opaque tokens bound to a RAP UI session and HTTP session, mapping to an
explorer VFS root. */
+public final class ExplorerFileRegistry {
+
+ private static final Map<String, ExplorerFileLease> BY_TOKEN = new
ConcurrentHashMap<>();
+ private static final Map<String, String> TOKEN_BY_UI_SESSION = new
ConcurrentHashMap<>();
+
+ private ExplorerFileRegistry() {}
+
+ public static synchronized ExplorerFileLease getOrCreate(UISession
uiSession, String rootVfsUri) {
+ if (uiSession == null || rootVfsUri == null) {
+ throw new IllegalArgumentException("uiSession and rootVfsUri are
required");
+ }
+ String uiId = uiSession.getId();
+ String existingToken = TOKEN_BY_UI_SESSION.get(uiId);
+ if (existingToken != null) {
+ ExplorerFileLease lease = BY_TOKEN.get(existingToken);
+ if (lease != null) {
+ lease.setRootVfsUri(rootVfsUri);
+ return lease;
+ }
+ TOKEN_BY_UI_SESSION.remove(uiId, existingToken);
+ }
+ String token = UUID.randomUUID().toString();
+ ExplorerFileLease lease = new ExplorerFileLease(token, uiSession,
rootVfsUri);
+ BY_TOKEN.put(token, lease);
+ TOKEN_BY_UI_SESSION.put(uiId, token);
+ uiSession.addUISessionListener(event -> remove(token, uiId));
+ return lease;
+ }
+
+ public static ExplorerFileLease find(String token) {
+ if (token == null) {
+ return null;
+ }
+ return BY_TOKEN.get(token);
+ }
+
+ static synchronized void remove(String token, String uiSessionId) {
+ BY_TOKEN.remove(token);
+ TOKEN_BY_UI_SESSION.remove(uiSessionId, token);
+ }
+
+ /** Test helper. */
+ static synchronized void clear() {
+ BY_TOKEN.clear();
+ TOKEN_BY_UI_SESSION.clear();
+ }
+}
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileServlet.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileServlet.java
new file mode 100644
index 0000000000..3195e8195f
--- /dev/null
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileServlet.java
@@ -0,0 +1,172 @@
+/*
+ * 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.hop.ui.hopgui.explorer;
+
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.ui.hopgui.HopGui;
+import
org.apache.hop.ui.hopgui.perspective.explorer.config.ExplorerPerspectiveConfigSingleton;
+import org.apache.hop.ui.hopgui.perspective.explorer.web.ExplorerFileServing;
+import org.eclipse.rap.rwt.service.UISession;
+
+/**
+ * Serves allow-listed files from the current explorer root for the RAP
Browser iframe. Mapped at
+ * {@code /explorer-file/*} so the document URL has a directory base for
relative {@code
+ * href}/{@code src}.
+ */
+public class ExplorerFileServlet extends HttpServlet {
+
+ public static final String CONTENT_SECURITY_POLICY =
+ "connect-src 'none'; form-action 'none'; base-uri 'none'";
+
+ static final long DEFAULT_MAX_BYTES = 16L * 1024L * 1024L;
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse
response)
+ throws IOException {
+ Optional<ExplorerFileServing.PathInfo> pathInfo =
+ ExplorerFileServing.parsePathInfo(request.getPathInfo());
+ if (pathInfo.isEmpty()) {
+ notFound(response);
+ return;
+ }
+ ExplorerFileLease lease =
ExplorerFileRegistry.find(pathInfo.get().token());
+ if (lease == null || !sessionMatches(request, lease)) {
+ notFound(response);
+ return;
+ }
+ UISession uiSession = lease.getUiSession();
+ if (uiSession == null) {
+ notFound(response);
+ return;
+ }
+
+ ServeResult result = new ServeResult();
+ try {
+ uiSession.exec(
+ () -> {
+ try {
+ serve(lease, pathInfo.get().relativePath(), result);
+ } catch (Exception e) {
+ result.error = e;
+ }
+ });
+ } catch (Exception e) {
+ LogChannel.UI.logDebug("Explorer file servlet: UI session is gone", e);
+ notFound(response);
+ return;
+ }
+ if (result.error != null) {
+ LogChannel.UI.logDebug("Explorer file servlet failed to read file",
result.error);
+ notFound(response);
+ return;
+ }
+ if (result.notFound) {
+ notFound(response);
+ return;
+ }
+ if (result.tooLarge) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ return;
+ }
+
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType(result.contentType);
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.setHeader("Cache-Control", "private, no-store");
+ response.setHeader("Content-Security-Policy", CONTENT_SECURITY_POLICY);
+ if (result.contentType != null &&
result.contentType.startsWith("application/pdf")) {
+ response.setHeader("Content-Disposition", "inline");
+ }
+ if (result.body != null) {
+ response.setContentLength(result.body.length);
+ response.getOutputStream().write(result.body);
+ }
+ }
+
+ private static void serve(ExplorerFileLease lease, String relativePath,
ServeResult result)
+ throws Exception {
+ IVariables variables = variables();
+ FileObject root = HopVfs.getFileObject(lease.getRootVfsUri(), variables);
+ Optional<FileObject> file = ExplorerFileServing.resolveUnderRoot(root,
relativePath);
+ if (file.isEmpty()) {
+ result.notFound = true;
+ return;
+ }
+ Optional<String> contentType =
ExplorerFileServing.contentType(relativePath);
+ if (contentType.isEmpty()) {
+ result.notFound = true;
+ return;
+ }
+ long size = file.get().getContent().getSize();
+ long maxBytes = maxBytes();
+ if (size > maxBytes) {
+ result.tooLarge = true;
+ return;
+ }
+ try (InputStream in = HopVfs.getInputStream(file.get())) {
+ result.body = in.readAllBytes();
+ }
+ result.contentType = contentType.get();
+ }
+
+ private static boolean sessionMatches(HttpServletRequest request,
ExplorerFileLease lease) {
+ HttpSession session = request.getSession(false);
+ String leaseSessionId = lease.getHttpSessionId();
+ return session != null && leaseSessionId != null &&
leaseSessionId.equals(session.getId());
+ }
+
+ private static IVariables variables() {
+ HopGui hopGui = HopGui.peekInstance();
+ return hopGui != null ? hopGui.getVariables() : new Variables();
+ }
+
+ static long maxBytes() {
+ try {
+ String option =
ExplorerPerspectiveConfigSingleton.getConfig().getFileLoadingMaxSize();
+ HopGui hopGui = HopGui.peekInstance();
+ String resolved = hopGui != null ? hopGui.getVariables().resolve(option)
: option;
+ return Const.toLong(resolved, 16) * 1024L * 1024L;
+ } catch (Exception e) {
+ return DEFAULT_MAX_BYTES;
+ }
+ }
+
+ private static void notFound(HttpServletResponse response) throws
IOException {
+ response.sendError(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ private static final class ServeResult {
+ byte[] body;
+ String contentType;
+ boolean notFound;
+ boolean tooLarge;
+ Exception error;
+ }
+}
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/RapExplorerFileService.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/RapExplorerFileService.java
new file mode 100644
index 0000000000..5c7af18b74
--- /dev/null
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/explorer/RapExplorerFileService.java
@@ -0,0 +1,74 @@
+/*
+ * 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.hop.ui.hopgui.explorer;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+import java.util.Optional;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.apache.hop.ui.hopgui.perspective.explorer.web.ExplorerFileServing;
+import
org.apache.hop.ui.hopgui.perspective.explorer.web.IHopWebExplorerFileService;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.service.UISession;
+
+/**
+ * Builds origin-relative {@code /explorer-file/{token}/...} URLs for the RAP
Browser widget. The
+ * path shape is what gives HTML a directory base for relative CSS, images,
and links.
+ */
+public final class RapExplorerFileService implements
IHopWebExplorerFileService {
+
+ @Override
+ public String urlFor(String vfsFilename, IVariables variables) {
+ if (Utils.isEmpty(vfsFilename)) {
+ return null;
+ }
+ try {
+ UISession uiSession = RWT.getUISession();
+ HttpServletRequest request = RWT.getRequest();
+ if (uiSession == null || request == null) {
+ return null;
+ }
+ HttpSession httpSession = uiSession.getHttpSession();
+ if (httpSession == null) {
+ return null;
+ }
+ ExplorerPerspective perspective = ExplorerPerspective.getInstance();
+ if (perspective == null || Utils.isEmpty(perspective.getRootFolder())) {
+ return null;
+ }
+ FileObject root = HopVfs.getFileObject(perspective.getRootFolder(),
variables);
+ FileObject file = HopVfs.getFileObject(vfsFilename, variables);
+ Optional<String> relative = ExplorerFileServing.relativePath(root, file);
+ if (relative.isEmpty() ||
!ExplorerFileServing.isAllowedExtension(relative.get())) {
+ return null;
+ }
+ ExplorerFileLease lease =
+ ExplorerFileRegistry.getOrCreate(uiSession, root.getName().getURI());
+ return ExplorerFileServing.buildPublicPath(
+ request.getContextPath(), lease.getToken(), relative.get());
+ } catch (Exception e) {
+ LogChannel.UI.logDebug("Could not build explorer file URL for '" +
vfsFilename + "'", e);
+ return null;
+ }
+ }
+}
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileServletTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileServletTest.java
new file mode 100644
index 0000000000..40a81c4835
--- /dev/null
+++
b/rap/src/test/java/org/apache/hop/ui/hopgui/explorer/ExplorerFileServletTest.java
@@ -0,0 +1,259 @@
+/*
+ * 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.hop.ui.hopgui.explorer;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.ui.hopgui.perspective.explorer.web.ExplorerFileServing;
+import org.eclipse.rap.rwt.service.UISession;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ExplorerFileServletTest {
+
+ private static final byte[] HTML =
+ "<html><body>Hello</body></html>".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] CSS =
"body{color:red}".getBytes(StandardCharsets.UTF_8);
+
+ private ExplorerFileServlet servlet;
+ private UISession uiSession;
+ private HttpSession httpSession;
+ private String ramRoot;
+ private ExplorerFileLease lease;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ ExplorerFileRegistry.clear();
+ servlet = new ExplorerFileServlet();
+ uiSession = mock(UISession.class);
+ httpSession = mock(HttpSession.class);
+ when(uiSession.getId()).thenReturn("ui-session");
+ when(uiSession.getHttpSession()).thenReturn(httpSession);
+ when(httpSession.getId()).thenReturn("http-session");
+ doAnswer(
+ invocation -> {
+ invocation.getArgument(0, Runnable.class).run();
+ return null;
+ })
+ .when(uiSession)
+ .exec(any(Runnable.class));
+
+ ramRoot = "ram:///" + UUID.randomUUID();
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ root.createFolder();
+ write(ramRoot + "/docs/index.html", HTML);
+ write(ramRoot + "/docs/assets/css/site.css", CSS);
+ write(ramRoot + "/docs/100%25 done.html", HTML);
+ write(ramRoot + "/docs/a%2520b.html", HTML);
+ write(ramRoot + "/report:2026-09-11.html", HTML);
+ write(ramRoot + "/secret.hpl",
"<pipeline/>".getBytes(StandardCharsets.UTF_8));
+
+ lease = ExplorerFileRegistry.getOrCreate(uiSession,
root.getName().getURI());
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ ExplorerFileRegistry.clear();
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ if (root.exists()) {
+ root.deleteAll();
+ }
+ }
+
+ @Test
+ void servesHtmlUnderRoot() throws Exception {
+ TestOutputStream output = new TestOutputStream();
+ HttpServletResponse response = response(output);
+
+ servlet.doGet(request("/" + lease.getToken() + "/docs/index.html"),
response);
+
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ verify(response).setContentType("text/html; charset=UTF-8");
+ verify(response).setHeader("X-Content-Type-Options", "nosniff");
+ verify(response).setHeader("Cache-Control", "private, no-store");
+ verify(response)
+ .setHeader("Content-Security-Policy",
ExplorerFileServlet.CONTENT_SECURITY_POLICY);
+ assertArrayEquals(HTML, output.bytes.toByteArray());
+ }
+
+ @Test
+ void sessionRotationAllowsAccess() throws Exception {
+ when(httpSession.getId()).thenReturn("rotated-session");
+ TestOutputStream output = new TestOutputStream();
+ HttpServletResponse response = response(output);
+
+ servlet.doGet(request("/" + lease.getToken() + "/docs/index.html"),
response);
+
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ assertArrayEquals(HTML, output.bytes.toByteArray());
+ }
+
+ @Test
+ void servesFilesWithPercentAndColon() throws Exception {
+ TestOutputStream output1 = new TestOutputStream();
+ HttpServletResponse response1 = response(output1);
+ servlet.doGet(request("/" + lease.getToken() + "/docs/100% done.html"),
response1);
+ verify(response1).setStatus(HttpServletResponse.SC_OK);
+ assertArrayEquals(HTML, output1.bytes.toByteArray());
+
+ TestOutputStream output2 = new TestOutputStream();
+ HttpServletResponse response2 = response(output2);
+ servlet.doGet(request("/" + lease.getToken() + "/report:2026-09-11.html"),
response2);
+ verify(response2).setStatus(HttpServletResponse.SC_OK);
+ assertArrayEquals(HTML, output2.bytes.toByteArray());
+ }
+
+ @Test
+ void servesPercentFileFromRelativePathUrl() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ serveFromFileObject(root, HopVfs.getFileObject(ramRoot + "/docs/100%25
done.html"));
+ serveFromFileObject(root, HopVfs.getFileObject(ramRoot +
"/docs/a%2520b.html"));
+ }
+
+ @Test
+ void servesCssNextToHtml() throws Exception {
+ TestOutputStream output = new TestOutputStream();
+ HttpServletResponse response = response(output);
+
+ servlet.doGet(request("/" + lease.getToken() +
"/docs/assets/css/site.css"), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ verify(response).setContentType("text/css; charset=UTF-8");
+ assertArrayEquals(CSS, output.bytes.toByteArray());
+ }
+
+ @Test
+ void unknownTokenIsNotFound() throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ servlet.doGet(request("/" + UUID.randomUUID() + "/docs/index.html"),
response);
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ verify(response, never()).getOutputStream();
+ }
+
+ @Test
+ void sessionMismatchIsNotFound() throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ HttpServletRequest request = request("/" + lease.getToken() +
"/docs/index.html");
+ HttpSession other = mock(HttpSession.class);
+ when(other.getId()).thenReturn("other-session");
+ when(request.getSession(false)).thenReturn(other);
+
+ servlet.doGet(request, response);
+
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ @Test
+ void pathEscapeIsNotFound() throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ servlet.doGet(request("/" + lease.getToken() + "/docs/../../secret.hpl"),
response);
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ @Test
+ void unknownExtensionIsNotFound() throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ servlet.doGet(request("/" + lease.getToken() + "/secret.hpl"), response);
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ @Test
+ void publicUrlIsRelative() {
+ String url = ExplorerFileServing.buildPublicPath("", lease.getToken(),
"docs/index.html");
+ assertTrue(url.startsWith("/explorer-file/"));
+ assertFalse(url.startsWith("http"));
+ assertEquals("/explorer-file/" + lease.getToken() + "/docs/index.html",
url);
+ }
+
+ /**
+ * Build the public URL from a real {@link FileObject} the way the explorer
tab does, then
+ * simulate the container decoding path-info once before the servlet sees it.
+ */
+ private void serveFromFileObject(FileObject root, FileObject file) throws
Exception {
+ String relative = ExplorerFileServing.relativePath(root,
file).orElseThrow();
+ String publicPath = ExplorerFileServing.buildPublicPath("",
lease.getToken(), relative);
+ String pathInfo =
+ URLDecoder.decode(publicPath.substring("/explorer-file".length()),
StandardCharsets.UTF_8);
+ TestOutputStream output = new TestOutputStream();
+ HttpServletResponse response = response(output);
+ servlet.doGet(request(pathInfo), response);
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ assertArrayEquals(HTML, output.bytes.toByteArray());
+ }
+
+ private HttpServletRequest request(String pathInfo) {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getPathInfo()).thenReturn(pathInfo);
+ when(request.getSession(false)).thenReturn(httpSession);
+ return request;
+ }
+
+ private static HttpServletResponse response(TestOutputStream output) throws
IOException {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(response.getOutputStream()).thenReturn(output);
+ return response;
+ }
+
+ private static void write(String path, byte[] content) throws Exception {
+ FileObject file = HopVfs.getFileObject(path);
+ file.getParent().createFolder();
+ try (OutputStream out = file.getContent().getOutputStream()) {
+ out.write(content);
+ }
+ }
+
+ private static final class TestOutputStream extends ServletOutputStream {
+ private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+
+ @Override
+ public void write(int value) {
+ bytes.write(value);
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setWriteListener(WriteListener listener) {}
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/ExplorerFileServing.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/ExplorerFileServing.java
new file mode 100644
index 0000000000..ba31e06cb1
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/ExplorerFileServing.java
@@ -0,0 +1,304 @@
+/*
+ * 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.hop.ui.hopgui.perspective.explorer.web;
+
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Pattern;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.FileSystemException;
+import org.apache.commons.vfs2.provider.UriParser;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * Path sandbox, content-type map, and public URL builder for Hop Web explorer
file serving. No RAP
+ * or SWT types so it can be unit-tested in {@code hop-ui}.
+ */
+public final class ExplorerFileServing {
+
+ public static final String SERVLET_PATH = "/explorer-file";
+
+ private static final Pattern WINDOWS_DRIVE = Pattern.compile("^[a-zA-Z]:.*");
+ private static final Pattern SCHEME_PREFIX =
Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*:");
+
+ static final Set<String> ALLOWED_EXTENSIONS =
+ Set.of(
+ "html", "htm", "css", "js", "mjs", "map", "png", "jpg", "jpeg",
"gif", "svg", "webp",
+ "ico", "bmp", "woff", "woff2", "ttf", "otf", "eot", "pdf");
+
+ private static final Map<String, String> CONTENT_TYPES =
+ Map.ofEntries(
+ Map.entry("html", "text/html; charset=UTF-8"),
+ Map.entry("htm", "text/html; charset=UTF-8"),
+ Map.entry("css", "text/css; charset=UTF-8"),
+ Map.entry("js", "text/javascript; charset=UTF-8"),
+ Map.entry("mjs", "text/javascript; charset=UTF-8"),
+ Map.entry("map", "application/json"),
+ Map.entry("png", "image/png"),
+ Map.entry("jpg", "image/jpeg"),
+ Map.entry("jpeg", "image/jpeg"),
+ Map.entry("gif", "image/gif"),
+ Map.entry("svg", "image/svg+xml"),
+ Map.entry("webp", "image/webp"),
+ Map.entry("ico", "image/x-icon"),
+ Map.entry("bmp", "image/bmp"),
+ Map.entry("woff", "font/woff"),
+ Map.entry("woff2", "font/woff2"),
+ Map.entry("ttf", "font/ttf"),
+ Map.entry("otf", "font/otf"),
+ Map.entry("eot", "application/vnd.ms-fontobject"),
+ Map.entry("pdf", "application/pdf"));
+
+ private ExplorerFileServing() {}
+
+ /**
+ * Relative path of {@code file} under {@code root}, or empty when the file
is not a descendant.
+ */
+ public static Optional<String> relativePath(FileObject root, FileObject file)
+ throws FileSystemException {
+ if (root == null || file == null) {
+ return Optional.empty();
+ }
+ if (!root.getName().isDescendent(file.getName())) {
+ return Optional.empty();
+ }
+ // getRelativeName() keeps '%' URI-escaped; sanitizeRelativePath() expects
decoded input.
+ return
sanitizeRelativePath(UriParser.decode(root.getName().getRelativeName(file.getName())));
+ }
+
+ /**
+ * Resolve {@code relativePath} under {@code root}. Empty when the path
escapes, is a folder, does
+ * not exist, or is not an allow-listed extension.
+ */
+ public static Optional<FileObject> resolveUnderRoot(FileObject root, String
relativePath)
+ throws FileSystemException {
+ if (root == null) {
+ return Optional.empty();
+ }
+ Optional<String> clean = sanitizeRelativePath(relativePath);
+ if (clean.isEmpty() || !isAllowedExtension(clean.get())) {
+ return Optional.empty();
+ }
+ FileObject resolved = root.resolveFile(clean.get().replace("%", "%25"));
+ if (resolved == null || !root.getName().isDescendent(resolved.getName())) {
+ return Optional.empty();
+ }
+ if (!resolved.exists() || resolved.isFolder()) {
+ return Optional.empty();
+ }
+ return Optional.of(resolved);
+ }
+
+ /**
+ * Normalize a relative path under the explorer root. Input is treated as
already URL-decoded
+ * (e.g. from {@code HttpServletRequest.getPathInfo()} or VFS {@code
FileName.getRelativeName()}).
+ * Rejects absolute paths, schemes, {@code ..} segments, and NUL.
+ */
+ public static Optional<String> sanitizeRelativePath(String path) {
+ if (Utils.isEmpty(path) || path.indexOf('\0') >= 0) {
+ return Optional.empty();
+ }
+ String normalized = path.replace('\\', '/');
+ if (normalized.startsWith("/")) {
+ return Optional.empty();
+ }
+ String[] parts = normalized.split("/");
+ if (parts.length > 0 && hasSchemeOrDrivePrefix(parts[0])) {
+ return Optional.empty();
+ }
+ List<String> out = new ArrayList<>(parts.length);
+ for (String part : parts) {
+ if (part.isEmpty() || ".".equals(part)) {
+ continue;
+ }
+ if ("..".equals(part) || "%2e%2e".equalsIgnoreCase(part)) {
+ return Optional.empty();
+ }
+ out.add(part);
+ }
+ if (out.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(String.join("/", out));
+ }
+
+ public static boolean hasSchemeOrDrivePrefix(String firstSegment) {
+ if (firstSegment == null || firstSegment.isEmpty()) {
+ return false;
+ }
+ if (WINDOWS_DRIVE.matcher(firstSegment).matches()) {
+ return true;
+ }
+ if (firstSegment.endsWith(":") &&
SCHEME_PREFIX.matcher(firstSegment).matches()) {
+ return true;
+ }
+ String lower = firstSegment.toLowerCase(Locale.ROOT);
+ return lower.startsWith("file:")
+ || lower.startsWith("http:")
+ || lower.startsWith("https:")
+ || lower.startsWith("ftp:");
+ }
+
+ /**
+ * Resolve a relative href against a document path that is itself under the
explorer root. Empty
+ * when the result would escape the root.
+ */
+ public static Optional<String> applyRelative(String documentRelativePath,
String href) {
+ Optional<String> document = sanitizeRelativePath(documentRelativePath);
+ if (document.isEmpty() || Utils.isEmpty(href) || href.indexOf('\0') >= 0) {
+ return Optional.empty();
+ }
+ String decodedHref;
+ try {
+ decodedHref = URLDecoder.decode(href.replace("+", "%2B"),
StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ decodedHref = decodedHref.replace('\\', '/');
+ if (decodedHref.startsWith("/")) {
+ return Optional.empty();
+ }
+ String[] hrefParts = decodedHref.split("/");
+ if (hrefParts.length > 0 && hasSchemeOrDrivePrefix(hrefParts[0])) {
+ return Optional.empty();
+ }
+ String combined = parentOf(document.get());
+ if (combined.isEmpty()) {
+ combined = decodedHref;
+ } else if (!decodedHref.isEmpty()) {
+ combined = combined + "/" + decodedHref;
+ }
+ List<String> stack = new ArrayList<>();
+ for (String part : combined.split("/")) {
+ if (part.isEmpty() || ".".equals(part)) {
+ continue;
+ }
+ if ("..".equals(part) || "%2e%2e".equalsIgnoreCase(part)) {
+ if (stack.isEmpty()) {
+ return Optional.empty();
+ }
+ stack.remove(stack.size() - 1);
+ continue;
+ }
+ stack.add(part);
+ }
+ if (stack.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(String.join("/", stack));
+ }
+
+ public static boolean isAllowedExtension(String relativePath) {
+ return
extensionOf(relativePath).filter(ALLOWED_EXTENSIONS::contains).isPresent();
+ }
+
+ public static Optional<String> contentType(String relativePath) {
+ return extensionOf(relativePath).map(CONTENT_TYPES::get);
+ }
+
+ /**
+ * Origin-relative public path. Never a scheme-absolute URL. {@code
contextPath} is the servlet
+ * context ({@code ""} or {@code /hop}); {@code relativePath} is already
sanitized.
+ */
+ public static String buildPublicPath(String contextPath, String token,
String relativePath) {
+ if (Utils.isEmpty(token) || Utils.isEmpty(relativePath)) {
+ throw new IllegalArgumentException("token and relativePath are
required");
+ }
+ String ctx = contextPath == null ? "" : contextPath;
+ if ("/".equals(ctx)) {
+ ctx = "";
+ } else if (ctx.endsWith("/")) {
+ ctx = ctx.substring(0, ctx.length() - 1);
+ }
+ return ctx + SERVLET_PATH + "/" + token + "/" + encodePath(relativePath);
+ }
+
+ public static boolean isUuidToken(String token) {
+ if (Utils.isEmpty(token)) {
+ return false;
+ }
+ try {
+ UUID.fromString(token);
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ /** Split {@code /{token}/{relative/path}} from a servlet path-info string.
*/
+ public static Optional<PathInfo> parsePathInfo(String pathInfo) {
+ if (Utils.isEmpty(pathInfo)) {
+ return Optional.empty();
+ }
+ String value = pathInfo.startsWith("/") ? pathInfo.substring(1) : pathInfo;
+ int slash = value.indexOf('/');
+ if (slash <= 0 || slash == value.length() - 1) {
+ return Optional.empty();
+ }
+ String token = value.substring(0, slash);
+ String relative = value.substring(slash + 1);
+ if (!isUuidToken(token)) {
+ return Optional.empty();
+ }
+ Optional<String> clean = sanitizeRelativePath(relative);
+ if (clean.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(new PathInfo(token, clean.get()));
+ }
+
+ public record PathInfo(String token, String relativePath) {}
+
+ private static Optional<String> extensionOf(String relativePath) {
+ if (Utils.isEmpty(relativePath)) {
+ return Optional.empty();
+ }
+ int slash = relativePath.lastIndexOf('/');
+ String base = slash >= 0 ? relativePath.substring(slash + 1) :
relativePath;
+ int dot = base.lastIndexOf('.');
+ if (dot <= 0 || dot == base.length() - 1) {
+ return Optional.empty();
+ }
+ return Optional.of(base.substring(dot + 1).toLowerCase(Locale.ROOT));
+ }
+
+ private static String parentOf(String relativePath) {
+ int slash = relativePath.lastIndexOf('/');
+ return slash <= 0 ? "" : relativePath.substring(0, slash);
+ }
+
+ private static String encodePath(String relativePath) {
+ String[] parts = relativePath.split("/");
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < parts.length; i++) {
+ if (i > 0) {
+ sb.append('/');
+ }
+ sb.append(URLEncoder.encode(parts[i],
StandardCharsets.UTF_8).replace("+", "%20"));
+ }
+ return sb.toString();
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/HopWebExplorerFileHelper.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/HopWebExplorerFileHelper.java
new file mode 100644
index 0000000000..ea43bc2dcc
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/HopWebExplorerFileHelper.java
@@ -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.hop.ui.hopgui.perspective.explorer.web;
+
+import org.apache.hop.core.variables.IVariables;
+
+/**
+ * Holder for the optional Hop Web explorer-file service. The RAP module sets
an implementation so
+ * HTML and PDF explorer tabs can {@code Browser.setUrl()} a path with a real
document base.
+ */
+public final class HopWebExplorerFileHelper {
+
+ private static IHopWebExplorerFileService service;
+
+ private HopWebExplorerFileHelper() {}
+
+ public static void setService(IHopWebExplorerFileService
explorerFileService) {
+ service = explorerFileService;
+ }
+
+ public static IHopWebExplorerFileService getService() {
+ return service;
+ }
+
+ /**
+ * Context-relative explorer-file URL, or {@code null} when not running in
Hop Web or the file
+ * cannot be served.
+ */
+ public static String urlFor(String vfsFilename, IVariables variables) {
+ if (service == null) {
+ return null;
+ }
+ return service.urlFor(vfsFilename, variables);
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/IHopWebExplorerFileService.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/IHopWebExplorerFileService.java
new file mode 100644
index 0000000000..bf4c4892b4
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/web/IHopWebExplorerFileService.java
@@ -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.hop.ui.hopgui.perspective.explorer.web;
+
+import org.apache.hop.core.variables.IVariables;
+
+/**
+ * Builds a same-origin URL that the Hop Web explorer browser widget can load
so relative CSS,
+ * images, and links resolve against the file's directory. Implemented in the
RAP module.
+ */
+public interface IHopWebExplorerFileService {
+
+ /**
+ * Context-relative URL for {@code vfsFilename} under the current explorer
root, or {@code null}
+ * when the file cannot be served (not under the root, unknown extension, no
RAP session).
+ *
+ * @param vfsFilename HopVfs path of the file to open
+ * @param variables variables used to resolve the path
+ * @return a path starting with {@code /} (never a {@code http://} URL), or
{@code null}
+ */
+ String urlFor(String vfsFilename, IVariables variables);
+}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/web/ExplorerFileServingTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/web/ExplorerFileServingTest.java
new file mode 100644
index 0000000000..fbbda7b21d
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/web/ExplorerFileServingTest.java
@@ -0,0 +1,269 @@
+/*
+ * 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.hop.ui.hopgui.perspective.explorer.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.UUID;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.vfs.HopVfs;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ExplorerFileServingTest {
+
+ private String ramRoot;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ ramRoot = "ram:///" + UUID.randomUUID();
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ root.createFolder();
+ write(ramRoot + "/docs/index.html", "<html></html>");
+ write(ramRoot + "/docs/assets/css/site.css", "body{}");
+ write(ramRoot + "/workflows/workflows/page.html", "<html></html>");
+ write(ramRoot + "/assets/css/x.css", "h1{}");
+ write(ramRoot + "/docs/100%25 done.html", "<html></html>");
+ write(ramRoot + "/docs/a%2520b.html", "<html></html>");
+ write(ramRoot + "/report:2026-09-11.html", "<html></html>");
+ write(ramRoot + "/secret.hpl", "<pipeline/>");
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ if (root.exists()) {
+ root.deleteAll();
+ }
+ }
+
+ @Test
+ void relativePathOfDescendant() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ FileObject file = HopVfs.getFileObject(ramRoot + "/docs/index.html");
+ assertEquals("docs/index.html", ExplorerFileServing.relativePath(root,
file).orElseThrow());
+ }
+
+ @Test
+ void relativePathFromFileObjectDecodesPercentNames() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ FileObject percentSpace = HopVfs.getFileObject(ramRoot + "/docs/100%25
done.html");
+ FileObject percentTwenty = HopVfs.getFileObject(ramRoot +
"/docs/a%2520b.html");
+
+ assertEquals(
+ "docs/100% done.html", ExplorerFileServing.relativePath(root,
percentSpace).orElseThrow());
+ assertEquals(
+ "docs/a%20b.html", ExplorerFileServing.relativePath(root,
percentTwenty).orElseThrow());
+
+ assertRoundTripFromFileObject(root, percentSpace);
+ assertRoundTripFromFileObject(root, percentTwenty);
+ }
+
+ @Test
+ void relativePathRejectsFileOutsideRoot() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot + "/docs");
+ FileObject file = HopVfs.getFileObject(ramRoot + "/secret.hpl");
+ assertTrue(ExplorerFileServing.relativePath(root, file).isEmpty());
+ }
+
+ @Test
+ void resolveUnderRootAllowsNestedFile() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ Optional<FileObject> resolved =
+ ExplorerFileServing.resolveUnderRoot(root, "docs/assets/css/site.css");
+ assertTrue(resolved.isPresent());
+ assertTrue(resolved.get().exists());
+ }
+
+ @Test
+ void resolveUnderRootAllowsPercentAndColon() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ Optional<FileObject> percentFile =
+ ExplorerFileServing.resolveUnderRoot(root, "docs/100% done.html");
+ assertTrue(percentFile.isPresent());
+ assertTrue(percentFile.get().exists());
+
+ Optional<FileObject> colonFile =
+ ExplorerFileServing.resolveUnderRoot(root, "report:2026-09-11.html");
+ assertTrue(colonFile.isPresent());
+ assertTrue(colonFile.get().exists());
+ }
+
+ @Test
+ void resolveUnderRootRejectsUnknownExtension() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ assertTrue(ExplorerFileServing.resolveUnderRoot(root,
"secret.hpl").isEmpty());
+ }
+
+ @Test
+ void resolveUnderRootRejectsFolder() throws Exception {
+ FileObject root = HopVfs.getFileObject(ramRoot);
+ assertTrue(ExplorerFileServing.resolveUnderRoot(root, "docs").isEmpty());
+ }
+
+ @Test
+ void sanitizeRejectsTraversal() {
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("../etc/passwd").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("docs/../../etc/passwd").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("/etc/passwd").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("file:///etc/passwd").isEmpty());
+ assertTrue(ExplorerFileServing.sanitizeRelativePath("").isEmpty());
+ assertTrue(ExplorerFileServing.sanitizeRelativePath(".").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("%2e%2e/etc/passwd").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("docs/%2e%2e/%2e%2e/etc/passwd").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("C:/Windows/win.ini").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("C:win.ini").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("file:secret.html").isEmpty());
+
assertTrue(ExplorerFileServing.sanitizeRelativePath("http://evil.com/x.html").isEmpty());
+ }
+
+ @Test
+ void sanitizeAcceptsNormalRelativePath() {
+ assertEquals(
+ "docs/index.html",
+
ExplorerFileServing.sanitizeRelativePath("docs/index.html").orElseThrow());
+ assertEquals(
+ "docs/index.html",
+
ExplorerFileServing.sanitizeRelativePath("./docs/index.html").orElseThrow());
+ assertEquals(
+ "docs/My File.html",
+ ExplorerFileServing.sanitizeRelativePath("docs/My
File.html").orElseThrow());
+ assertEquals(
+ "docs/100% done.html",
+ ExplorerFileServing.sanitizeRelativePath("docs/100%
done.html").orElseThrow());
+ assertEquals(
+ "docs/a%20b.html",
+
ExplorerFileServing.sanitizeRelativePath("docs/a%20b.html").orElseThrow());
+ assertEquals(
+ "report:2026-09-11.html",
+
ExplorerFileServing.sanitizeRelativePath("report:2026-09-11.html").orElseThrow());
+ assertEquals(
+ "docs/report:2026-09-11.html",
+
ExplorerFileServing.sanitizeRelativePath("docs/report:2026-09-11.html").orElseThrow());
+ }
+
+ @Test
+ void nestedHtmlRelativeCssStaysUnderRoot() {
+ Optional<String> resolved =
+ ExplorerFileServing.applyRelative(
+ "workflows/workflows/page.html", "../../assets/css/x.css");
+ assertEquals("assets/css/x.css", resolved.orElseThrow());
+ }
+
+ @Test
+ void applyRelativeRejectsEscape() {
+ assertTrue(
+ ExplorerFileServing.applyRelative("docs/index.html",
"../../../etc/passwd").isEmpty());
+ }
+
+ @Test
+ void contentTypes() {
+ assertEquals(
+ "text/html; charset=UTF-8",
+ ExplorerFileServing.contentType("docs/index.html").orElseThrow());
+ assertEquals(
+ "text/css; charset=UTF-8",
+ ExplorerFileServing.contentType("assets/css/site.css").orElseThrow());
+ assertEquals(
+ "text/javascript; charset=UTF-8",
ExplorerFileServing.contentType("app.js").orElseThrow());
+ assertEquals("image/png",
ExplorerFileServing.contentType("logo.png").orElseThrow());
+ assertEquals("application/pdf",
ExplorerFileServing.contentType("doc.pdf").orElseThrow());
+ assertTrue(ExplorerFileServing.contentType("secret.hpl").isEmpty());
+ assertTrue(ExplorerFileServing.contentType("noext").isEmpty());
+ }
+
+ @Test
+ void allowedExtensions() {
+ assertTrue(ExplorerFileServing.isAllowedExtension("a.html"));
+ assertTrue(ExplorerFileServing.isAllowedExtension("a.PDF"));
+ assertFalse(ExplorerFileServing.isAllowedExtension("a.hpl"));
+ assertFalse(ExplorerFileServing.isAllowedExtension("a.hwf"));
+ assertFalse(ExplorerFileServing.isAllowedExtension(".htaccess"));
+ }
+
+ @Test
+ void publicPathIsRelativeAndNeverHttp() {
+ String token = UUID.randomUUID().toString();
+ String path = ExplorerFileServing.buildPublicPath("", token,
"docs/index.html");
+ assertEquals("/explorer-file/" + token + "/docs/index.html", path);
+ assertTrue(path.startsWith("/"));
+ assertFalse(path.startsWith("http"));
+
+ String withContext = ExplorerFileServing.buildPublicPath("/hop", token,
"docs/index.html");
+ assertEquals("/hop/explorer-file/" + token + "/docs/index.html",
withContext);
+ assertFalse(withContext.startsWith("http"));
+
+ String encoded = ExplorerFileServing.buildPublicPath("/", token, "docs/My
File.html");
+ assertEquals("/explorer-file/" + token + "/docs/My%20File.html", encoded);
+ }
+
+ @Test
+ void parsePathInfo() {
+ String token = UUID.randomUUID().toString();
+ ExplorerFileServing.PathInfo info =
+ ExplorerFileServing.parsePathInfo("/" + token +
"/docs/index.html").orElseThrow();
+ assertEquals(token, info.token());
+ assertEquals("docs/index.html", info.relativePath());
+
+ assertTrue(ExplorerFileServing.parsePathInfo("/" + token).isEmpty());
+
assertTrue(ExplorerFileServing.parsePathInfo("/not-a-uuid/docs/index.html").isEmpty());
+ assertTrue(ExplorerFileServing.parsePathInfo("/" + token +
"/../secret.hpl").isEmpty());
+ }
+
+ @Test
+ void buildPublicPathRequiresArgs() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> ExplorerFileServing.buildPublicPath("", "", "docs/a.html"));
+ }
+
+ /**
+ * Produce a public URL from a real {@link FileObject}, simulate the servlet
container decoding
+ * path-info once, and resolve back to the same file.
+ */
+ private static void assertRoundTripFromFileObject(FileObject root,
FileObject file)
+ throws Exception {
+ String relative = ExplorerFileServing.relativePath(root,
file).orElseThrow();
+ String token = UUID.randomUUID().toString();
+ String publicPath = ExplorerFileServing.buildPublicPath("", token,
relative);
+ String pathInfo =
+ URLDecoder.decode(publicPath.substring("/explorer-file".length()),
StandardCharsets.UTF_8);
+ ExplorerFileServing.PathInfo parsed =
ExplorerFileServing.parsePathInfo(pathInfo).orElseThrow();
+ Optional<FileObject> resolved =
+ ExplorerFileServing.resolveUnderRoot(root, parsed.relativePath());
+ assertTrue(resolved.isPresent());
+ assertTrue(resolved.get().exists());
+ assertEquals(file.getName().getURI(), resolved.get().getName().getURI());
+ }
+
+ private static void write(String path, String content) throws Exception {
+ FileObject file = HopVfs.getFileObject(path);
+ file.getParent().createFolder();
+ try (OutputStream out = file.getContent().getOutputStream()) {
+ out.write(content.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/web/HopWebExplorerFileHelperTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/web/HopWebExplorerFileHelperTest.java
new file mode 100644
index 0000000000..0ca7acb18c
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/web/HopWebExplorerFileHelperTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.hop.ui.hopgui.perspective.explorer.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class HopWebExplorerFileHelperTest {
+
+ @AfterEach
+ void tearDown() {
+ HopWebExplorerFileHelper.setService(null);
+ }
+
+ @Test
+ void urlForIsNullWhenNoServiceIsRegistered() {
+ HopWebExplorerFileHelper.setService(null);
+ assertNull(HopWebExplorerFileHelper.urlFor("/tmp/index.html", null));
+ }
+
+ @Test
+ void urlForDelegatesToTheRegisteredService() {
+ HopWebExplorerFileHelper.setService((filename, variables) ->
"/explorer-file/t/" + filename);
+ assertEquals(
+ "/explorer-file/t/docs/index.html",
+ HopWebExplorerFileHelper.urlFor("docs/index.html", null));
+ }
+}