diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java index 3151cc314810..98d88314c786 100644 --- a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java +++ b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java @@ -379,11 +379,20 @@ private void serveFile(StaplerRequest2 req, StaplerResponse2 rsp, VirtualFile ro return; } - // for binary files, provide the file name for download - rsp.setHeader("Content-Disposition", "inline; filename=" + baseFile.getName()); + try (DirectoryBrowserSupportFilter.Context context = applyFilters(req, baseFile, in, length, true)) { + in = context.getInputStream(); + length = context.getLength(); + String fileName = context.getFileName(); - // pseudo file name to let the Stapler set text/plain; ensure charset for non-ASCII text - rsp.serveFile(req, in, lastModified, -1, length, "mime-type:text/plain;charset=UTF-8"); + // for binary files, provide the file name for download + rsp.setHeader("Content-Disposition", "inline; filename=" + fileName); + + // pseudo file name to let Stapler set text/plain; ensure charset for non-ASCII text + rsp.serveFile(req, in, lastModified, -1, length, "mime-type:text/plain;charset=UTF-8"); + } catch (IOException ioe) { + LOGGER.log(Level.WARNING, "Failed to serve file for " + baseFile, ioe); + rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } } else { if (resourceToken != null) { // redirect to second domain @@ -407,16 +416,42 @@ private void serveFile(StaplerRequest2 req, StaplerResponse2 rsp, VirtualFile ro rsp.sendError(HttpServletResponse.SC_NOT_FOUND); return; } - String fileName = baseFile.getName(); - String mimeType = Jenkins.get().getServletContext().getMimeType(fileName); - if (mimeType != null && mimeType.startsWith("text/")) { - // include charset=UTF-8 for text files to prevent browser encoding guessing - rsp.serveFile(req, in, lastModified, -1, length, "mime-type:" + mimeType + ";charset=UTF-8"); - } else { - rsp.serveFile(req, in, lastModified, -1, length, fileName); + + try (DirectoryBrowserSupportFilter.Context context = applyFilters(req, baseFile, in, length, false)) { + in = context.getInputStream(); + length = context.getLength(); + String fileName = context.getFileName(); + + String mimeType = Jenkins.get().getServletContext().getMimeType(fileName); + if (mimeType != null && mimeType.startsWith("text/")) { + // include charset=UTF-8 for text files to prevent browser encoding guessing + rsp.serveFile(req, in, lastModified, -1, length, "mime-type:" + mimeType + ";charset=UTF-8"); + } else { + rsp.serveFile(req, in, lastModified, -1, length, fileName); + } + } catch (IOException ioe) { + LOGGER.log(Level.WARNING, "Failed to serve file for " + baseFile, ioe); + rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + } + } + + private DirectoryBrowserSupportFilter.Context applyFilters(StaplerRequest2 req, VirtualFile baseFile, InputStream in, long length, boolean view) throws IOException { + DirectoryBrowserSupportFilter.Context context = new DirectoryBrowserSupportFilter.Context(baseFile, req, in, length, view); + for (DirectoryBrowserSupportFilter filter : DirectoryBrowserSupportFilter.all()) { + try { + DirectoryBrowserSupportFilter.Context next = filter.filter(context); + if (next != null) { + context = next; } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to filter stream for " + baseFile + " using " + filter, e); + context.close(); + throw new IOException("Filter execution failed: " + filter.getClass().getName(), e); } } + return context; } private record IsAbsolute(String fragment) implements ControllerToAgentCallable { diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java b/core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java new file mode 100644 index 000000000000..b8c0aabb4b6a --- /dev/null +++ b/core/src/main/java/hudson/model/DirectoryBrowserSupportFilter.java @@ -0,0 +1,179 @@ +/* + * The MIT License + * + * Copyright (c) 2026, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.model; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import jenkins.util.VirtualFile; +import org.apache.commons.io.IOUtils; +import org.kohsuke.stapler.StaplerRequest2; + +/** + * Extension point that allows plugins to filter or transform input streams and metadata served by {@link DirectoryBrowserSupport}. + * + *

Plugins can implement this extension point to inspect, decode, or transform files served through + * {@link DirectoryBrowserSupport} (such as build artifacts or workspace files). Potential use cases include, for example: + *

+ * + * @since TODO + */ +public abstract class DirectoryBrowserSupportFilter implements ExtensionPoint { + + /** + * Context object passed to {@link DirectoryBrowserSupportFilter} implementations. + * + *

This object tracks the file stream and metadata during filtering. Superseded input streams replaced + * via {@link #setInputStream(InputStream)} are automatically tracked and closed when this context is closed. + */ + public static final class Context implements AutoCloseable { + private final VirtualFile file; + private final StaplerRequest2 request; + private final boolean view; + private final List supersededStreams = new ArrayList<>(); + private InputStream inputStream; + private long length; + private String fileName; + + public Context(@NonNull VirtualFile file, @Nullable StaplerRequest2 request, @NonNull InputStream inputStream, long length, boolean view) { + this.file = file; + this.request = request; + this.inputStream = inputStream; + this.length = length; + this.view = view; + this.fileName = file.getName(); + } + + public Context(@NonNull VirtualFile file, @Nullable StaplerRequest2 request, @NonNull InputStream inputStream, long length) { + this(file, request, inputStream, length, false); + } + + /** + * Gets the {@link VirtualFile} being served. + */ + @NonNull + public VirtualFile getFile() { + return file; + } + + /** + * Gets the current {@link StaplerRequest2}, if available. + */ + @Nullable + public StaplerRequest2 getRequest() { + return request; + } + + /** + * Returns whether the request was made in view mode (e.g., {@code /*view*\/}). + */ + public boolean isView() { + return view; + } + + /** + * Gets the current {@link InputStream} for the file content. + */ + @NonNull + public InputStream getInputStream() { + return inputStream; + } + + /** + * Sets a new {@link InputStream} for the file content. + * + *

If a previous stream is replaced by an independent stream (for example, reading all bytes into a new + * {@link java.io.ByteArrayInputStream}), the previous stream is marked as superseded and will be closed + * when this context is closed. + */ + public void setInputStream(@NonNull InputStream inputStream) { + if (this.inputStream != inputStream) { + this.supersededStreams.add(this.inputStream); + this.inputStream = inputStream; + } + } + + /** + * Gets the length of the stream content in bytes. + */ + public long getLength() { + return length; + } + + /** + * Sets the length of the stream content in bytes, or {@code -1} if the length is unknown. + */ + public void setLength(long length) { + this.length = length; + } + + /** + * Gets the filename used for response headers and MIME type matching. + */ + @NonNull + public String getFileName() { + return fileName; + } + + /** + * Sets a new filename for the served content. + */ + public void setFileName(@NonNull String fileName) { + this.fileName = fileName; + } + + @Override + public void close() { + for (InputStream s : supersededStreams) { + IOUtils.closeQuietly(s); + } + } + } + + /** + * Filters or transforms the given file serving context. + * + * @param context the current file serving context + * @return the transformed context (or the same context), or {@code null} if no changes are made + * @throws IOException if an I/O error occurs during filtering + */ + @Nullable + public abstract Context filter(@NonNull Context context) throws IOException; + + /** + * All registered {@link DirectoryBrowserSupportFilter} instances. + */ + public static ExtensionList all() { + return ExtensionList.lookup(DirectoryBrowserSupportFilter.class); + } +} diff --git a/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java b/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java index 8f31c39dba6d..0362659acd3c 100644 --- a/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java +++ b/test/src/test/java/hudson/model/DirectoryBrowserSupportTest.java @@ -1558,4 +1558,39 @@ public boolean delete() { } } + @TestExtension({"directoryBrowserSupportFilterTest", "directoryBrowserSupportNonViewFilterTest"}) + public static class TestGzipFilter extends DirectoryBrowserSupportFilter { + @Override + public Context filter(Context context) throws IOException { + if (context.getFile().getName().endsWith(".custom")) { + String modified = "MODIFIED: " + new String(context.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + byte[] bytes = modified.getBytes(java.nio.charset.StandardCharsets.UTF_8); + context.setInputStream(new java.io.ByteArrayInputStream(bytes)); + context.setLength(bytes.length); + } + return context; + } + } + + @Test + public void directoryBrowserSupportFilterTest() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + p.setScm(new SingleFileSCM("test.custom", "hello world")); + p.getPublishersList().add(new ArtifactArchiver("test.custom")); + j.buildAndAssertSuccess(p); + + org.htmlunit.Page page = j.createWebClient().goTo("job/" + p.getName() + "/lastSuccessfulBuild/artifact/test.custom/*view*/", "text/plain"); + assertEquals("MODIFIED: hello world", page.getWebResponse().getContentAsString()); + } + + @Test + public void directoryBrowserSupportNonViewFilterTest() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + p.setScm(new SingleFileSCM("test.custom", "hello world")); + p.getPublishersList().add(new ArtifactArchiver("test.custom")); + j.buildAndAssertSuccess(p); + + org.htmlunit.Page page = j.createWebClient().goTo("job/" + p.getName() + "/lastSuccessfulBuild/artifact/test.custom", "text/plain"); + assertEquals("MODIFIED: hello world", page.getWebResponse().getContentAsString()); + } }