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));
+  }
+}

Reply via email to