diff --git a/documentation/jetty-asciidoctor-extensions/pom.xml b/documentation/jetty-asciidoctor-extensions/pom.xml index 837001dd6e7..3a26cde9661 100644 --- a/documentation/jetty-asciidoctor-extensions/pom.xml +++ b/documentation/jetty-asciidoctor-extensions/pom.xml @@ -5,7 +5,7 @@ org.eclipse.jetty.documentation documentation - 12.0.10-SNAPSHOT + 12.0.12-SNAPSHOT jetty-asciidoctor-extensions jar diff --git a/documentation/jetty-documentation/pom.xml b/documentation/jetty-documentation/pom.xml index e74a4cee774..92901abd5eb 100644 --- a/documentation/jetty-documentation/pom.xml +++ b/documentation/jetty-documentation/pom.xml @@ -5,10 +5,10 @@ org.eclipse.jetty.documentation documentation - 12.0.10-SNAPSHOT + 12.0.12-SNAPSHOT jetty-documentation - pom + jar Documentation :: Guides @@ -49,15 +49,20 @@ org.eclipse.jetty - jetty-home - ${project.version} - zip - true + jetty-infinispan-common org.eclipse.jetty jetty-infinispan-embedded-query + + org.eclipse.jetty + jetty-infinispan-remote-query + + + org.eclipse.jetty + jetty-io + org.eclipse.jetty jetty-jmx diff --git a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc index 7412b3e400e..0fb32ba165c 100644 --- a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc +++ b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/server/http/server-http-handler-use.adoc @@ -654,3 +654,10 @@ Below you can find an example of how to setup `DefaultServlet`: ---- include::../../{doc_code}/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java[tags=defaultServlet] ---- + +If you wish to serve resources from a non-default location, then the `ResourceServlet` should be used instead of the `DefaultServlet`. Below you can find an example of how to setup both a `DefaultServlet` and a `ResourceServlet`: + +[source,java,indent=0] +---- +include::../../{doc_code}/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java[tags=resourceServlet] +---- \ No newline at end of file diff --git a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java index 40aa6abdd0a..63c365b9cb9 100644 --- a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java +++ b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java @@ -34,6 +34,7 @@ import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.ee10.servlet.DefaultServlet; +import org.eclipse.jetty.ee10.servlet.ResourceServlet; import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.ee10.webapp.WebAppContext; @@ -1220,11 +1221,32 @@ public class HTTPServerDocs // Add the DefaultServlet to serve static content. ServletHolder servletHolder = context.addServlet(DefaultServlet.class, "/"); // Configure the DefaultServlet with init-parameters. - servletHolder.setInitParameter("resourceBase", "/path/to/static/resources/"); + servletHolder.setInitParameter("maxCacheSize", "8388608"); + servletHolder.setInitParameter("dirAllowed", "true"); servletHolder.setAsyncSupported(true); // end::defaultServlet[] } + public void resourceServlet() + { + // tag::resourceServlet[] + // Create a ServletContextHandler with contextPath. + ServletContextHandler context = new ServletContextHandler(); + context.setContextPath("/app"); + + // Add the ResourceServlet to serve static content from a specific location. + ServletHolder servletHolder = context.addServlet(ResourceServlet.class, "/static/*"); + // Configure the ResourceServlet with init-parameters. + servletHolder.setInitParameter("baseResource", "/absolute/path/to/static/resources/"); + servletHolder.setInitParameter("pathInfoOnly", "true"); + servletHolder.setAsyncSupported(true); + + // Add the DefaultServlet to serve static content from the resource base + ServletHolder defaultHolder = context.addServlet(DefaultServlet.class, "/"); + defaultHolder.setAsyncSupported(true); + // end::resourceServlet[] + } + public void serverGzipHandler() throws Exception { // tag::serverGzipHandler[] diff --git a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java index 40aa6abdd0a..91a7f12abb2 100644 --- a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java +++ b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java @@ -1220,7 +1220,7 @@ public class HTTPServerDocs // Add the DefaultServlet to serve static content. ServletHolder servletHolder = context.addServlet(DefaultServlet.class, "/"); // Configure the DefaultServlet with init-parameters. - servletHolder.setInitParameter("resourceBase", "/path/to/static/resources/"); + servletHolder.setInitParameter("baseResource", "/path/to/static/resources/"); servletHolder.setAsyncSupported(true); // end::defaultServlet[] } diff --git a/documentation/pom.xml b/documentation/pom.xml index 9bcaf3a041c..67d665780ad 100644 --- a/documentation/pom.xml +++ b/documentation/pom.xml @@ -14,5 +14,7 @@ jetty + jetty-asciidoctor-extensions + jetty-documentation diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java index 00904781c0f..d1ad6b93ca3 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java @@ -919,6 +919,12 @@ public interface Request extends Attributes, Content.Source { return _request; } + + @Override + public String toString() + { + return "%s@%x{%s}".formatted(getClass().getSimpleName(), hashCode(), getWrapped()); + } } @SuppressWarnings("unchecked") diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/DefaultServlet.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/DefaultServlet.java index 7b1a0969bff..506018f51d7 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/DefaultServlet.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/DefaultServlet.java @@ -13,374 +13,36 @@ package org.eclipse.jetty.ee10.servlet; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.file.InvalidPathException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Optional; -import java.util.StringTokenizer; +import java.util.concurrent.atomic.AtomicBoolean; -import jakarta.servlet.AsyncContext; -import jakarta.servlet.DispatcherType; -import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; -import jakarta.servlet.UnavailableException; -import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.MappingMatch; -import org.eclipse.jetty.http.CompressedContentFormat; -import org.eclipse.jetty.http.HttpException; -import org.eclipse.jetty.http.HttpField; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpStatus; -import org.eclipse.jetty.http.MimeTypes; -import org.eclipse.jetty.http.content.FileMappingHttpContentFactory; -import org.eclipse.jetty.http.content.HttpContent; -import org.eclipse.jetty.http.content.PreCompressedHttpContentFactory; -import org.eclipse.jetty.http.content.ResourceHttpContentFactory; -import org.eclipse.jetty.http.content.ValidatingCachingHttpContentFactory; -import org.eclipse.jetty.http.content.VirtualHttpContentFactory; -import org.eclipse.jetty.io.ByteBufferPool; import org.eclipse.jetty.server.Context; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.ResourceService; -import org.eclipse.jetty.server.Response; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.handler.ContextHandler; -import org.eclipse.jetty.util.Blocker; -import org.eclipse.jetty.util.Callback; -import org.eclipse.jetty.util.ExceptionUtil; -import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.URIUtil; -import org.eclipse.jetty.util.resource.Resource; -import org.eclipse.jetty.util.resource.Resources; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static org.eclipse.jetty.util.URIUtil.encodePath; - /** - *

The default Servlet, normally mapped to {@code /}, that handles static resources.

- *

The following init parameters are supported:

- *
- *
acceptRanges
- *
- * Use {@code true} to accept range requests, defaults to {@code true}. - *
- *
baseResource
- *
- * Defaults to the context's baseResource. - * The root directory to look for static resources. - *
- *
cacheControl
- *
- * The value of the {@code Cache-Control} header. - * If omitted, no {@code Cache-Control} header is generated in responses. - * By default is omitted. - *
- *
cacheValidationTime
- *
- * How long in milliseconds a resource is cached. - * If omitted, defaults to {@code 1000} ms. - * Use {@code -1} to cache forever or {@code 0} to not cache. - *
- *
dirAllowed
- *
- * Use {@code true} to serve directory listing if no welcome file is found. - * Otherwise responds with {@code 403 Forbidden}. - * Defaults to {@code true}. - *
- *
encodingHeaderCacheSize
- *
- * Max number of cached {@code Accept-Encoding} entries. - * Use {@code -1} for the default value (100), {@code 0} for no cache. - *
- *
etags
- *
- * Use {@code true} to generate ETags in responses. - * Defaults to {@code false}. - *
- *
maxCachedFiles
- *
- * The max number of cached static resources. - * Use {@code -1} for the default value (2048) or {@code 0} for no cache. - *
- *
maxCachedFileSize
- *
- * The max size in bytes of a single cached static resource. - * Use {@code -1} for the default value (128 MiB) or {@code 0} for no cache. - *
- *
maxCacheSize
- *
- * The max size in bytes of the cache for static resources. - * Use {@code -1} for the default value (256 MiB) or {@code 0} for no cache. - *
- *
otherGzipFileExtensions
- *
- * A comma-separated list of extensions of files whose content is implicitly - * gzipped. - * Defaults to {@code .svgz}. - *
- *
precompressed
- *
- * Omitted by default, so that no pre-compressed content will be served. - * If set to {@code true}, the default set of pre-compressed formats will be used. - * Otherwise can be set to a comma-separated list of {@code encoding=extension} pairs, - * such as: {@code br=.br,gzip=.gz,bzip2=.bz}, where {@code encoding} is used as the - * value for the {@code Content-Encoding} header. - *
- *
redirectWelcome
- *
- * Use {@code true} to redirect welcome files, otherwise they are forwarded. - * Defaults to {@code false}. - *
- *
stylesheet
- *
- * Defaults to the {@code Server}'s default stylesheet, {@code jetty-dir.css}. - * The path of a custom stylesheet to style the directory listing HTML. - *
- *
useFileMappedBuffer
- *
- * Use {@code true} to use file mapping to serve static resources. - * Defaults to {@code false}. - *
- *
welcomeServlets
- *
- * Use {@code false} to only serve welcome resources from the file system. - * Use {@code true} to dispatch welcome resources to a matching Servlet - * (for example mapped to {@code *.welcome}), when the welcome resources - * does not exist on file system. - * Use {@code exact} to dispatch welcome resource to a Servlet whose mapping - * is exactly the same as the welcome resource (for example {@code /index.welcome}), - * when the welcome resources does not exist on file system. - * Defaults to {@code false}. - *
- *
+ *

The {@code DefaultServlet}, is a specialization of the {@link ResourceServlet} to be mapped to {@code /} as the "default" + * servlet for a context. + *

+ *

+ * In addition to the servlet init parameters that can be used to configure any {@link ResourceServlet}, the DefaultServlet + * also looks at {@link ServletContext#getInitParameter(String)} for any parameter starting with {@link #CONTEXT_INIT}, which + * is then stripped and the resulting name interpreted as a {@link ResourceServlet} init parameter. + *

+ *

+ * To serve static content other than as the {@code DefaultServlet} mapped to "/", please use the {@link ResourceServlet} directly. + * The {@code DefaultServlet} will warn if it is used other than as the default servlet. In future, this may become a fatal error. + *

*/ -public class DefaultServlet extends HttpServlet +public class DefaultServlet extends ResourceServlet { private static final Logger LOG = LoggerFactory.getLogger(DefaultServlet.class); public static final String CONTEXT_INIT = "org.eclipse.jetty.servlet.Default."; - - private ServletContextHandler _contextHandler; - private ServletResourceService _resourceService; - private WelcomeServletMode _welcomeServletMode; - - public ResourceService getResourceService() - { - return _resourceService; - } - - @Override - public void init() throws ServletException - { - _contextHandler = initContextHandler(getServletContext()); - _resourceService = new ServletResourceService(_contextHandler); - _resourceService.setWelcomeFactory(_resourceService); - Resource baseResource = _contextHandler.getBaseResource(); - - String rb = getInitParameter("baseResource", "resourceBase"); - if (rb != null) - { - try - { - baseResource = URIUtil.isRelative(rb) ? _contextHandler.getBaseResource().resolve(rb) : _contextHandler.newResource(rb); - } - catch (Exception e) - { - LOG.warn("Unable to create baseResource from {}", rb, e); - throw new UnavailableException(e.toString()); - } - } - if (baseResource != null && !(baseResource.isDirectory() && baseResource.isReadable())) - LOG.warn("baseResource {} is not a readable directory", baseResource); - - List precompressedFormats = parsePrecompressedFormats(getInitParameter("precompressed"), - getInitBoolean("gzip"), _resourceService.getPrecompressedFormats()); - - // Try to get factory from ServletContext attribute. - HttpContent.Factory contentFactory = (HttpContent.Factory)getServletContext().getAttribute(HttpContent.Factory.class.getName()); - if (contentFactory == null) - { - MimeTypes mimeTypes = _contextHandler.getMimeTypes(); - contentFactory = new ResourceHttpContentFactory(baseResource, mimeTypes); - - // Use the servers default stylesheet unless there is one explicitly set by an init param. - Resource styleSheet = _contextHandler.getServer().getDefaultStyleSheet(); - String stylesheetParam = getInitParameter("stylesheet"); - if (stylesheetParam != null) - { - try - { - HttpContent styleSheetContent = contentFactory.getContent(stylesheetParam); - Resource s = styleSheetContent == null ? null : styleSheetContent.getResource(); - if (Resources.isReadableFile(s)) - styleSheet = s; - else - LOG.warn("Stylesheet {} does not exist", stylesheetParam); - } - catch (Exception e) - { - if (LOG.isDebugEnabled()) - LOG.warn("Unable to use stylesheet: {}", stylesheetParam, e); - else - LOG.warn("Unable to use stylesheet: {} - {}", stylesheetParam, e.toString()); - } - } - - if (getInitBoolean("useFileMappedBuffer", false)) - contentFactory = new FileMappingHttpContentFactory(contentFactory); - - contentFactory = new VirtualHttpContentFactory(contentFactory, styleSheet, "text/css"); - contentFactory = new PreCompressedHttpContentFactory(contentFactory, precompressedFormats); - - int maxCacheSize = getInitInt("maxCacheSize", -2); - int maxCachedFileSize = getInitInt("maxCachedFileSize", -2); - int maxCachedFiles = getInitInt("maxCachedFiles", -2); - long cacheValidationTime = getInitParameter("cacheValidationTime") != null ? Long.parseLong(getInitParameter("cacheValidationTime")) : -2; - if (maxCachedFiles != -2 || maxCacheSize != -2 || maxCachedFileSize != -2 || cacheValidationTime != -2) - { - ByteBufferPool bufferPool = getByteBufferPool(_contextHandler); - ValidatingCachingHttpContentFactory cached = new ValidatingCachingHttpContentFactory(contentFactory, - (cacheValidationTime > -2) ? cacheValidationTime : Duration.ofSeconds(1).toMillis(), bufferPool); - contentFactory = cached; - if (maxCacheSize >= 0) - cached.setMaxCacheSize(maxCacheSize); - if (maxCachedFileSize >= 0) - cached.setMaxCachedFileSize(maxCachedFileSize); - if (maxCachedFiles >= 0) - cached.setMaxCachedFiles(maxCachedFiles); - } - } - _resourceService.setHttpContentFactory(contentFactory); - - if (_contextHandler.getWelcomeFiles() == null) - _contextHandler.setWelcomeFiles(new String[]{"index.html", "index.jsp"}); - - _resourceService.setAcceptRanges(getInitBoolean("acceptRanges", _resourceService.isAcceptRanges())); - _resourceService.setDirAllowed(getInitBoolean("dirAllowed", _resourceService.isDirAllowed())); - boolean redirectWelcome = getInitBoolean("redirectWelcome", false); - _resourceService.setWelcomeMode(redirectWelcome ? ResourceService.WelcomeMode.REDIRECT : ResourceService.WelcomeMode.SERVE); - _resourceService.setPrecompressedFormats(precompressedFormats); - _resourceService.setEtags(getInitBoolean("etags", _resourceService.isEtags())); - - _welcomeServletMode = WelcomeServletMode.NONE; - String welcomeServlets = getInitParameter("welcomeServlets"); - if (welcomeServlets != null) - { - welcomeServlets = welcomeServlets.toLowerCase(Locale.ENGLISH); - _welcomeServletMode = switch (welcomeServlets) - { - case "true" -> WelcomeServletMode.MATCH; - case "exact" -> WelcomeServletMode.EXACT; - default -> WelcomeServletMode.NONE; - }; - } - - int encodingHeaderCacheSize = getInitInt("encodingHeaderCacheSize", -1); - if (encodingHeaderCacheSize >= 0) - _resourceService.setEncodingCacheSize(encodingHeaderCacheSize); - - String cc = getInitParameter("cacheControl"); - if (cc != null) - _resourceService.setCacheControl(cc); - - List gzipEquivalentFileExtensions = new ArrayList<>(); - String otherGzipExtensions = getInitParameter("otherGzipFileExtensions"); - if (otherGzipExtensions != null) - { - //comma separated list - StringTokenizer tok = new StringTokenizer(otherGzipExtensions, ",", false); - while (tok.hasMoreTokens()) - { - String s = tok.nextToken().trim(); - gzipEquivalentFileExtensions.add((s.charAt(0) == '.' ? s : "." + s)); - } - } - else - { - // .svgz files are gzipped svg files and must be served with Content-Encoding:gzip - gzipEquivalentFileExtensions.add(".svgz"); - } - _resourceService.setGzipEquivalentFileExtensions(gzipEquivalentFileExtensions); - - if (LOG.isDebugEnabled()) - { - LOG.debug(" .baseResource = {}", baseResource); - LOG.debug(" .resourceService = {}", _resourceService); - LOG.debug(" .welcomeServletMode = {}", _welcomeServletMode); - } - } - - private static ByteBufferPool getByteBufferPool(ContextHandler contextHandler) - { - if (contextHandler == null) - return ByteBufferPool.NON_POOLING; - Server server = contextHandler.getServer(); - if (server == null) - return ByteBufferPool.NON_POOLING; - return server.getByteBufferPool(); - } - - private String getInitParameter(String name, String... deprecated) - { - String value = getInitParameter(name); - if (value != null) - return value; - - for (String d : deprecated) - { - value = getInitParameter(d); - if (value != null) - { - LOG.warn("Deprecated {} used instead of {}", d, name); - return value; - } - } - - return null; - } - - private List parsePrecompressedFormats(String precompressed, Boolean gzip, List dft) - { - if (precompressed == null && gzip == null) - { - return dft; - } - List ret = new ArrayList<>(); - if (precompressed != null && precompressed.indexOf('=') > 0) - { - for (String pair : precompressed.split(",")) - { - String[] setting = pair.split("="); - String encoding = setting[0].trim(); - String extension = setting[1].trim(); - ret.add(new CompressedContentFormat(encoding, extension)); - if (gzip == Boolean.TRUE && !ret.contains(CompressedContentFormat.GZIP)) - ret.add(CompressedContentFormat.GZIP); - } - } - else if (precompressed != null) - { - if (Boolean.parseBoolean(precompressed)) - { - ret.add(CompressedContentFormat.BR); - ret.add(CompressedContentFormat.GZIP); - } - } - else if (gzip == Boolean.TRUE) - { - // gzip handling is for backwards compatibility with older Jetty - ret.add(CompressedContentFormat.GZIP); - } - return ret; - } + private final AtomicBoolean warned = new AtomicBoolean(false); /** *

@@ -405,519 +67,55 @@ public class DefaultServlet extends HttpServlet return value; } - private Boolean getInitBoolean(String name) - { - String value = getInitParameter(name); - if (value == null || value.length() == 0) - return null; - return (value.startsWith("t") || - value.startsWith("T") || - value.startsWith("y") || - value.startsWith("Y") || - value.startsWith("1")); - } - - private boolean getInitBoolean(String name, boolean dft) - { - return Optional.ofNullable(getInitBoolean(name)).orElse(dft); - } - - private int getInitInt(String name, int dft) - { - String value = getInitParameter(name); - if (value != null && value.length() > 0) - return Integer.parseInt(value); - return dft; - } - - protected ServletContextHandler initContextHandler(ServletContext servletContext) - { - if (servletContext instanceof ServletContextHandler.ServletContextApi api) - return api.getContext().getServletContextHandler(); - - Context context = ContextHandler.getCurrentContext(); - if (context instanceof ContextHandler.ScopedContext scopedContext) - return scopedContext.getContextHandler(); - - throw new IllegalArgumentException("The servletContext " + servletContext + " " + - servletContext.getClass().getName() + " is not " + ContextHandler.ScopedContext.class.getName()); - } - @Override - protected void doGet(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException + public void init() throws ServletException { - String includedServletPath = (String)httpServletRequest.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH); - String encodedPathInContext = getEncodedPathInContext(httpServletRequest, includedServletPath); - boolean included = includedServletPath != null; - - if (LOG.isDebugEnabled()) - LOG.debug("doGet(hsReq={}, hsResp={}) pathInContext={}, included={}", httpServletRequest, httpServletResponse, encodedPathInContext, included); - - try - { - HttpContent content = _resourceService.getContent(encodedPathInContext, ServletContextRequest.getServletContextRequest(httpServletRequest)); - if (LOG.isDebugEnabled()) - LOG.debug("content = {}", content); - - if (content == null || Resources.missing(content.getResource())) - { - if (included) - { - /* https://github.com/jakartaee/servlet/blob/6.0.0-RELEASE/spec/src/main/asciidoc/servlet-spec-body.adoc#93-the-include-method - * 9.3 - If the default servlet is the target of a RequestDispatch.include() and the requested - * resource does not exist, then the default servlet MUST throw FileNotFoundException. - * If the exception isn’t caught and handled, and the response - * hasn’t been committed, the status code MUST be set to 500. - */ - throw new FileNotFoundException(encodedPathInContext); - } - - // no content - httpServletResponse.sendError(404); - } - else - { - // lookup the core request and response as wrapped by the ServletContextHandler - ServletContextRequest servletContextRequest = ServletContextRequest.getServletContextRequest(httpServletRequest); - ServletContextResponse servletContextResponse = servletContextRequest.getServletContextResponse(); - ServletChannel servletChannel = servletContextRequest.getServletChannel(); - - // If the servlet request has not been wrapped, - // we can use the core request directly, - // otherwise wrap the servlet request as a core request - Request coreRequest = httpServletRequest instanceof ServletApiRequest - ? servletChannel.getRequest() - : ServletCoreRequest.wrap(httpServletRequest); - - // If the servlet response has been wrapped and has been written to, - // then the servlet response must be wrapped as a core response - // otherwise we can use the core response directly. - boolean useServletResponse = !(httpServletResponse instanceof ServletApiResponse) || servletContextResponse.isWritingOrStreaming(); - Response coreResponse = useServletResponse - ? new ServletCoreResponse(coreRequest, httpServletResponse, included) - : servletChannel.getResponse(); - - // If the core response is already committed then do nothing more - if (coreResponse.isCommitted()) - { - if (LOG.isDebugEnabled()) - LOG.debug("Response already committed for {}", coreRequest.getHttpURI()); - return; - } - - // Get the content length before we may wrap the content - long contentLength = content.getContentLengthValue(); - - // Servlet Filters could be interacting with the Response already. - if (useServletResponse) - content = new UnknownLengthHttpContent(content); - - // The character encoding may be forced - String characterEncoding = servletContextResponse.getRawCharacterEncoding(); - if (characterEncoding != null) - content = new ForcedCharacterEncodingHttpContent(content, characterEncoding); - - // If async is supported and the unwrapped content is larger than an output buffer - if (httpServletRequest.isAsyncSupported() && - (contentLength < 0 || contentLength > coreRequest.getConnectionMetaData().getHttpConfiguration().getOutputBufferSize())) - { - // send the content asynchronously - AsyncContext asyncContext = httpServletRequest.startAsync(); - Callback callback = new AsyncContextCallback(asyncContext, httpServletResponse); - _resourceService.doGet(coreRequest, coreResponse, callback, content); - } - else - { - // send the content blocking - try (Blocker.Callback callback = Blocker.callback()) - { - _resourceService.doGet(coreRequest, coreResponse, callback, content); - callback.block(); - } - catch (Exception e) - { - throw new ServletException(e); - } - } - } - } - catch (InvalidPathException e) - { - if (LOG.isDebugEnabled()) - LOG.debug("InvalidPathException for pathInContext: {}", encodedPathInContext, e); - if (included) - throw new FileNotFoundException(encodedPathInContext); - httpServletResponse.setStatus(404); - } + if ("true".equalsIgnoreCase(getInitParameter("pathInfoOnly"))) + LOG.warn("DefaultServlet pathInfoOnly is set to true. Use ResourceServlet instead."); + super.init(); } + /** + * Get the path in the context, of the resource to serve for a request. + * The default implementation considers the {@link jakarta.servlet.http.HttpServletMapping} of the request and + * any {@link Dispatcher#INCLUDE_SERVLET_PATH} attributes that may be set. + * @param request The request + * @param included {@code true} if the request is for an included resource + * @return The encoded URI path of the resource to server, relative to the resource base. + */ + @Override + protected String getEncodedPathInContext(HttpServletRequest request, boolean included) + { + String deprecatedPath = getEncodedPathInContext(request, (String)(included ? request.getAttribute(Dispatcher.INCLUDE_SERVLET_PATH) : null)); + if (deprecatedPath != null) + return deprecatedPath; + + if (request.getHttpServletMapping().getMappingMatch() != MappingMatch.DEFAULT) + { + if (warned.compareAndSet(false, true)) + LOG.warn("Incorrect mapping for DefaultServlet at %s. Use ResourceServlet".formatted(request.getHttpServletMapping().getPattern())); + return super.getEncodedPathInContext(request, included); + } + + if (included) + { + if (request.getAttribute(Dispatcher.INCLUDE_SERVLET_PATH) instanceof String servletPath) + return URIUtil.encodePath(servletPath); + + // must be an include of a named dispatcher. Just use the whole URI + return URIUtil.encodePath(request.getServletPath()); + } + + if (request instanceof ServletApiRequest apiRequest) + // Strip the context path from the canonically encoded path, so no need to re-encode (and mess up %2F etc.) + return Context.getPathInContext(request.getContextPath(), apiRequest.getRequest().getHttpURI().getCanonicalPath()); + + return URIUtil.encodePath(request.getServletPath()); + } + + @Deprecated(forRemoval = true) protected String getEncodedPathInContext(HttpServletRequest req, String includedServletPath) { - if (includedServletPath != null) - return encodePath(getIncludedPathInContext(req, includedServletPath, !isDefaultMapping(req))); - else if (!isDefaultMapping(req)) - { - //a match via an extension mapping will more than likely - //have no path info - String path = req.getPathInfo(); - if (StringUtil.isEmpty(path) && - MappingMatch.EXTENSION.equals(req.getHttpServletMapping().getMappingMatch())) - path = req.getServletPath(); - - return encodePath(path); - } - else if (req instanceof ServletApiRequest apiRequest) - return Context.getPathInContext(req.getContextPath(), apiRequest.getRequest().getHttpURI().getCanonicalPath()); - else - return Context.getPathInContext(req.getContextPath(), URIUtil.canonicalPath(req.getRequestURI())); - } - - @Override - protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException - { - if (LOG.isDebugEnabled()) - LOG.debug("doHead(req={}, resp={}) (calling doGet())", req, resp); - doGet(req, resp); - } - - @Override - protected void doTrace(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException - { - // Always return 405: Method Not Allowed for DefaultServlet - resp.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); - } - - @Override - protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException - { - // override to eliminate TRACE that the default HttpServlet impl adds - resp.setHeader("Allow", "GET, HEAD, OPTIONS"); - } - - private class ServletResourceService extends ResourceService implements ResourceService.WelcomeFactory - { - private final ServletContextHandler _servletContextHandler; - - private ServletResourceService(ServletContextHandler servletContextHandler) - { - _servletContextHandler = servletContextHandler; - } - - @Override - public String getWelcomeTarget(HttpContent content, Request coreRequest) - { - String[] welcomes = _servletContextHandler.getWelcomeFiles(); - if (welcomes == null) - return null; - String pathInContext = Request.getPathInContext(coreRequest); - String welcomeTarget = null; - Resource base = content.getResource(); - if (Resources.isReadableDirectory(base)) - { - for (String welcome : welcomes) - { - String welcomeInContext = URIUtil.addPaths(pathInContext, welcome); - - // If the welcome resource is a file, it has - // precedence over resources served by Servlets. - Resource welcomePath = content.getResource().resolve(welcome); - if (Resources.isReadableFile(welcomePath)) - return welcomeInContext; - - // Check whether a Servlet may serve the welcome resource. - if (_welcomeServletMode != WelcomeServletMode.NONE && welcomeTarget == null) - { - if (!isDefaultMapping(getServletRequest(coreRequest)) && !isIncluded(getServletRequest(coreRequest))) - welcomeTarget = URIUtil.addPaths(getServletRequest(coreRequest).getPathInfo(), welcome); - - ServletHandler.MappedServlet entry = _servletContextHandler.getServletHandler().getMappedServlet(welcomeInContext); - // Is there a different Servlet that may serve the welcome resource? - if (entry != null && entry.getServletHolder().getServletInstance() != DefaultServlet.this) - { - if (_welcomeServletMode == WelcomeServletMode.MATCH || entry.getPathSpec().getDeclaration().equals(welcomeInContext)) - { - welcomeTarget = welcomeInContext; - // Do not break the loop, because we want to try other welcome resources - // that may be files and take precedence over Servlet welcome resources. - } - } - } - } - } - return welcomeTarget; - } - - @Override - protected void serveWelcome(Request request, Response response, Callback callback, String welcomeTarget) throws IOException - { - HttpServletRequest servletRequest = getServletRequest(request); - HttpServletResponse servletResponse = getServletResponse(response); - - boolean included = isIncluded(servletRequest); - - RequestDispatcher dispatcher = servletRequest.getServletContext().getRequestDispatcher(welcomeTarget); - if (dispatcher == null) - { - // We know that the welcome target exists and can be served. - Response.writeError(request, response, callback, HttpStatus.INTERNAL_SERVER_ERROR_500); - return; - } - - try - { - if (included) - { - dispatcher.include(servletRequest, servletResponse); - } - else - { - servletRequest.setAttribute("org.eclipse.jetty.server.welcome", welcomeTarget); - dispatcher.forward(servletRequest, servletResponse); - } - callback.succeeded(); - } - catch (ServletException e) - { - callback.failed(e); - } - } - - @Override - protected void rehandleWelcome(Request request, Response response, Callback callback, String welcomeTarget) throws IOException - { - serveWelcome(request, response, callback, welcomeTarget); - } - - @Override - protected void writeHttpError(Request coreRequest, Response coreResponse, Callback callback, int statusCode) - { - if (LOG.isDebugEnabled()) - LOG.debug("writeHttpError(coreRequest={}, coreResponse={}, callback={}, statusCode={})", coreRequest, coreResponse, callback, statusCode); - writeHttpError(coreRequest, coreResponse, callback, statusCode, null, null); - } - - @Override - protected void writeHttpError(Request coreRequest, Response coreResponse, Callback callback, Throwable cause) - { - if (LOG.isDebugEnabled()) - LOG.debug("writeHttpError(coreRequest={}, coreResponse={}, callback={}, cause={})", coreRequest, coreResponse, callback, cause, cause); - - int statusCode = HttpStatus.INTERNAL_SERVER_ERROR_500; - String reason = null; - if (cause instanceof HttpException httpException) - { - statusCode = httpException.getCode(); - reason = httpException.getReason(); - } - writeHttpError(coreRequest, coreResponse, callback, statusCode, reason, cause); - } - - @Override - protected void writeHttpError(Request coreRequest, Response coreResponse, Callback callback, int statusCode, String reason, Throwable cause) - { - if (LOG.isDebugEnabled()) - LOG.debug("writeHttpError(coreRequest={}, coreResponse={}, callback={}, statusCode={}, reason={}, cause={})", coreRequest, coreResponse, callback, statusCode, reason, cause, cause); - HttpServletRequest request = getServletRequest(coreRequest); - HttpServletResponse response = getServletResponse(coreResponse); - try - { - if (isIncluded(request)) - return; - if (cause != null) - request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, cause); - response.sendError(statusCode, reason); - } - catch (IOException e) - { - // TODO: Need a better exception? - throw new RuntimeException(e); - } - finally - { - callback.succeeded(); - } - } - - @Override - protected boolean passConditionalHeaders(Request request, Response response, HttpContent content, Callback callback) throws IOException - { - boolean included = isIncluded(getServletRequest(request)); - if (included) - return false; - return super.passConditionalHeaders(request, response, content, callback); - } - - private HttpServletRequest getServletRequest(Request request) - { - ServletCoreRequest servletCoreRequest = Request.as(request, ServletCoreRequest.class); - if (servletCoreRequest != null) - return servletCoreRequest.getServletRequest(); - - ServletContextRequest servletContextRequest = Request.as(request, ServletContextRequest.class); - if (servletContextRequest != null) - return servletContextRequest.getServletApiRequest(); - - throw new IllegalStateException("instanceof " + request.getClass()); - } - - private HttpServletResponse getServletResponse(Response response) - { - ServletCoreResponse servletCoreResponse = Response.as(response, ServletCoreResponse.class); - if (servletCoreResponse != null) - return servletCoreResponse.getServletResponse(); - - ServletContextResponse servletContextResponse = Response.as(response, ServletContextResponse.class); - if (servletContextResponse != null) - return servletContextResponse.getServletApiResponse(); - - throw new IllegalStateException("instanceof " + response.getClass()); - } - } - - static String getIncludedPathInContext(HttpServletRequest request, String includedServletPath, boolean isPathInfoOnly) - { - String servletPath = isPathInfoOnly ? "/" : includedServletPath; - String pathInfo = (String)request.getAttribute(RequestDispatcher.INCLUDE_PATH_INFO); - return URIUtil.addPaths(servletPath, pathInfo); - } - - private static boolean isIncluded(HttpServletRequest request) - { - return request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) != null; - } - - protected boolean isDefaultMapping(HttpServletRequest req) - { - if (req.getHttpServletMapping().getMappingMatch() == MappingMatch.DEFAULT) - return true; - return (req.getDispatcherType() != DispatcherType.REQUEST) && "default".equals(getServletConfig().getServletName()); - } - - /** - * Wrap an existing HttpContent with one that takes has an unknown/unspecified length. - */ - private static class UnknownLengthHttpContent extends HttpContent.Wrapper - { - public UnknownLengthHttpContent(HttpContent content) - { - super(content); - } - - @Override - public HttpField getContentLength() - { - return null; - } - - @Override - public long getContentLengthValue() - { - return -1; - } - } - - private static class ForcedCharacterEncodingHttpContent extends HttpContent.Wrapper - { - private final String characterEncoding; - private final String contentType; - - public ForcedCharacterEncodingHttpContent(HttpContent content, String characterEncoding) - { - super(Objects.requireNonNull(content)); - this.characterEncoding = characterEncoding; - if (content.getContentTypeValue() == null || content.getResource().isDirectory()) - { - this.contentType = null; - } - else - { - String mimeType = content.getContentTypeValue(); - int idx = mimeType.indexOf(";charset"); - if (idx >= 0) - mimeType = mimeType.substring(0, idx); - this.contentType = mimeType + ";charset=" + characterEncoding; - } - } - - @Override - public HttpField getContentType() - { - return new HttpField(HttpHeader.CONTENT_TYPE, this.contentType); - } - - @Override - public String getContentTypeValue() - { - return this.contentType; - } - - @Override - public String getCharacterEncoding() - { - return this.characterEncoding; - } - } - - /** - *

The different modes a welcome resource may be served by a Servlet.

- */ - private enum WelcomeServletMode - { - /** - *

Welcome targets are not served by Servlets.

- *

The welcome target must exist as a file on the filesystem.

- */ - NONE, - /** - *

Welcome target that exist as files on the filesystem are - * served, otherwise a matching Servlet may serve the welcome target.

- */ - MATCH, - /** - *

Welcome target that exist as files on the filesystem are - * served, otherwise an exact matching Servlet may serve the welcome target.

- */ - EXACT - } - - private static class AsyncContextCallback implements Callback - { - private final AsyncContext _asyncContext; - private final HttpServletResponse _response; - - private AsyncContextCallback(AsyncContext asyncContext, HttpServletResponse response) - { - _asyncContext = asyncContext; - _response = response; - } - - @Override - public void succeeded() - { - _asyncContext.complete(); - } - - @Override - public void failed(Throwable x) - { - try - { - if (LOG.isDebugEnabled()) - LOG.debug("AsyncContextCallback failed {}", _asyncContext, x); - // It is known that this callback is only failed if the response is already committed, - // thus we can only abort the response here. - _response.sendError(-1); - } - catch (IOException e) - { - ExceptionUtil.addSuppressedIfNotAssociated(x, e); - } - finally - { - _asyncContext.complete(); - } - if (LOG.isDebugEnabled()) - LOG.debug("Async get failed", x); - } + return null; } } diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/Dispatcher.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/Dispatcher.java index 70f610fc991..3ecf4a3d6b4 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/Dispatcher.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/Dispatcher.java @@ -258,6 +258,12 @@ public class Dispatcher implements RequestDispatcher return null; return vals.toArray(new String[0]); } + + @Override + public String toString() + { + return "%s@%x{%s}".formatted(getClass().getSimpleName(), hashCode(), getRequest()); + } } private class ForwardRequest extends ParameterRequestWrapper diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ResourceServlet.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ResourceServlet.java new file mode 100644 index 00000000000..130e637b13b --- /dev/null +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ResourceServlet.java @@ -0,0 +1,931 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.ee10.servlet; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.StringTokenizer; + +import jakarta.servlet.AsyncContext; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.UnavailableException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletMapping; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.CompressedContentFormat; +import org.eclipse.jetty.http.HttpException; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.content.FileMappingHttpContentFactory; +import org.eclipse.jetty.http.content.HttpContent; +import org.eclipse.jetty.http.content.PreCompressedHttpContentFactory; +import org.eclipse.jetty.http.content.ResourceHttpContentFactory; +import org.eclipse.jetty.http.content.ValidatingCachingHttpContentFactory; +import org.eclipse.jetty.http.content.VirtualHttpContentFactory; +import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.server.Context; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.ResourceService; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.Blocker; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.ExceptionUtil; +import org.eclipse.jetty.util.URIUtil; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.Resources; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

A Servlet that handles static resources.

+ *

The following init parameters are supported:

+ *
+ *
acceptRanges
+ *
+ * Use {@code true} to accept range requests, defaults to {@code true}. + *
+ *
baseResource
+ *
+ * The root directory to look for static resources. Defaults to the context's baseResource. Relative URI + * are {@link Resource#resolve(String) resolved} against the context's {@link ServletContextHandler#getBaseResource()} + * base resource, all other values are resolved using {@link ServletContextHandler#newResource(String)}. + *
+ *
cacheControl
+ *
+ * The value of the {@code Cache-Control} header. + * If omitted, no {@code Cache-Control} header is generated in responses. + * By default is omitted. + *
+ *
cacheValidationTime
+ *
+ * How long in milliseconds a resource is cached. + * If omitted, defaults to {@code 1000} ms. + * Use {@code -1} to cache forever or {@code 0} to not cache. + *
+ *
dirAllowed
+ *
+ * Use {@code true} to serve directory listing if no welcome file is found. + * Otherwise responds with {@code 403 Forbidden}. + * Defaults to {@code true}. + *
+ *
encodingHeaderCacheSize
+ *
+ * Max number of cached {@code Accept-Encoding} entries. + * Use {@code -1} for the default value (100), {@code 0} for no cache. + *
+ *
etags
+ *
+ * Use {@code true} to generate ETags in responses. + * Defaults to {@code false}. + *
+ *
maxCachedFiles
+ *
+ * The max number of cached static resources. + * Use {@code -1} for the default value (2048) or {@code 0} for no cache. + *
+ *
maxCachedFileSize
+ *
+ * The max size in bytes of a single cached static resource. + * Use {@code -1} for the default value (128 MiB) or {@code 0} for no cache. + *
+ *
maxCacheSize
+ *
+ * The max size in bytes of the cache for static resources. + * Use {@code -1} for the default value (256 MiB) or {@code 0} for no cache. + *
+ *
otherGzipFileExtensions
+ *
+ * A comma-separated list of extensions of files whose content is implicitly + * gzipped. + * Defaults to {@code .svgz}. + *
+ *
pathInfoOnly
+ *
+ * Use {@code true} to use only the pathInfo portion of a PATH (aka prefix) match + * as obtained from {@link HttpServletRequest#getPathInfo()}. + * Defaults to {@code true}. + *
+ *
precompressed
+ *
+ * Omitted by default, so that no pre-compressed content will be served. + * If set to {@code true}, the default set of pre-compressed formats will be used. + * Otherwise can be set to a comma-separated list of {@code encoding=extension} pairs, + * such as: {@code br=.br,gzip=.gz,bzip2=.bz}, where {@code encoding} is used as the + * value for the {@code Content-Encoding} header. + *
+ *
redirectWelcome
+ *
+ * Use {@code true} to redirect welcome files, otherwise they are forwarded. + * Defaults to {@code false}. + *
+ *
stylesheet
+ *
+ * Defaults to the {@code Server}'s default stylesheet, {@code jetty-dir.css}. + * The path of a custom stylesheet to style the directory listing HTML. + *
+ *
useFileMappedBuffer
+ *
+ * Use {@code true} to use file mapping to serve static resources. + * Defaults to {@code false}. + *
+ *
welcomeServlets
+ *
+ * Use {@code false} to only serve welcome resources from the file system. + * Use {@code true} to dispatch welcome resources to a matching Servlet + * (for example mapped to {@code *.welcome}), when the welcome resources + * does not exist on file system. + * Use {@code exact} to dispatch welcome resource to a Servlet whose mapping + * is exactly the same as the welcome resource (for example {@code /index.welcome}), + * when the welcome resources does not exist on file system. + * Defaults to {@code false}. + *
+ *
+ */ +public class ResourceServlet extends HttpServlet +{ + private static final Logger LOG = LoggerFactory.getLogger(ResourceServlet.class); + + private ServletResourceService _resourceService; + private WelcomeServletMode _welcomeServletMode; + private boolean _pathInfoOnly; + + public ResourceService getResourceService() + { + return _resourceService; + } + + @Override + public void init() throws ServletException + { + ServletContextHandler contextHandler = initContextHandler(getServletContext()); + _resourceService = new ServletResourceService(contextHandler); + _resourceService.setWelcomeFactory(_resourceService); + Resource baseResource = contextHandler.getBaseResource(); + + String rb = getInitParameter("baseResource", "resourceBase"); + if (rb != null) + { + try + { + baseResource = URIUtil.isRelative(rb) ? baseResource.resolve(rb) : contextHandler.newResource(rb); + if (baseResource.isAlias()) + baseResource = contextHandler.newResource(baseResource.getRealURI()); + } + catch (Exception e) + { + LOG.warn("Unable to create baseResource from {}", rb, e); + throw new UnavailableException(e.toString()); + } + } + if (baseResource != null && !(baseResource.isDirectory() && baseResource.isReadable())) + LOG.warn("baseResource {} is not a readable directory", baseResource); + + List precompressedFormats = parsePrecompressedFormats(getInitParameter("precompressed"), + getInitBoolean("gzip"), _resourceService.getPrecompressedFormats()); + + // Try to get factory from ServletContext attribute. + HttpContent.Factory contentFactory = (HttpContent.Factory)getServletContext().getAttribute(HttpContent.Factory.class.getName()); + if (contentFactory == null) + { + MimeTypes mimeTypes = contextHandler.getMimeTypes(); + contentFactory = new ResourceHttpContentFactory(baseResource, mimeTypes); + + // Use the servers default stylesheet unless there is one explicitly set by an init param. + Resource styleSheet = contextHandler.getServer().getDefaultStyleSheet(); + String stylesheetParam = getInitParameter("stylesheet"); + if (stylesheetParam != null) + { + try + { + HttpContent styleSheetContent = contentFactory.getContent(stylesheetParam); + Resource s = styleSheetContent == null ? null : styleSheetContent.getResource(); + if (Resources.isReadableFile(s)) + styleSheet = s; + else + LOG.warn("Stylesheet {} does not exist", stylesheetParam); + } + catch (Exception e) + { + if (LOG.isDebugEnabled()) + LOG.warn("Unable to use stylesheet: {}", stylesheetParam, e); + else + LOG.warn("Unable to use stylesheet: {} - {}", stylesheetParam, e.toString()); + } + } + + if (getInitBoolean("useFileMappedBuffer", false)) + contentFactory = new FileMappingHttpContentFactory(contentFactory); + + contentFactory = new VirtualHttpContentFactory(contentFactory, styleSheet, "text/css"); + contentFactory = new PreCompressedHttpContentFactory(contentFactory, precompressedFormats); + + int maxCacheSize = getInitInt("maxCacheSize", -2); + int maxCachedFileSize = getInitInt("maxCachedFileSize", -2); + int maxCachedFiles = getInitInt("maxCachedFiles", -2); + long cacheValidationTime = getInitParameter("cacheValidationTime") != null ? Long.parseLong(getInitParameter("cacheValidationTime")) : -2; + if (maxCachedFiles != -2 || maxCacheSize != -2 || maxCachedFileSize != -2 || cacheValidationTime != -2) + { + ByteBufferPool bufferPool = getByteBufferPool(contextHandler); + ValidatingCachingHttpContentFactory cached = new ValidatingCachingHttpContentFactory(contentFactory, + (cacheValidationTime > -2) ? cacheValidationTime : Duration.ofSeconds(1).toMillis(), bufferPool); + contentFactory = cached; + if (maxCacheSize >= 0) + cached.setMaxCacheSize(maxCacheSize); + if (maxCachedFileSize >= 0) + cached.setMaxCachedFileSize(maxCachedFileSize); + if (maxCachedFiles >= 0) + cached.setMaxCachedFiles(maxCachedFiles); + } + } + _resourceService.setHttpContentFactory(contentFactory); + + if (contextHandler.getWelcomeFiles() == null) + contextHandler.setWelcomeFiles(new String[]{"index.html", "index.jsp"}); + + _resourceService.setAcceptRanges(getInitBoolean("acceptRanges", _resourceService.isAcceptRanges())); + _resourceService.setDirAllowed(getInitBoolean("dirAllowed", _resourceService.isDirAllowed())); + boolean redirectWelcome = getInitBoolean("redirectWelcome", false); + _resourceService.setWelcomeMode(redirectWelcome ? ResourceService.WelcomeMode.REDIRECT : ResourceService.WelcomeMode.SERVE); + _resourceService.setPrecompressedFormats(precompressedFormats); + _resourceService.setEtags(getInitBoolean("etags", _resourceService.isEtags())); + + _welcomeServletMode = WelcomeServletMode.NONE; + String welcomeServlets = getInitParameter("welcomeServlets"); + if (welcomeServlets != null) + { + welcomeServlets = welcomeServlets.toLowerCase(Locale.ENGLISH); + _welcomeServletMode = switch (welcomeServlets) + { + case "true" -> WelcomeServletMode.MATCH; + case "exact" -> WelcomeServletMode.EXACT; + default -> WelcomeServletMode.NONE; + }; + } + + int encodingHeaderCacheSize = getInitInt("encodingHeaderCacheSize", -1); + if (encodingHeaderCacheSize >= 0) + _resourceService.setEncodingCacheSize(encodingHeaderCacheSize); + + String cc = getInitParameter("cacheControl"); + if (cc != null) + _resourceService.setCacheControl(cc); + + List gzipEquivalentFileExtensions = new ArrayList<>(); + String otherGzipExtensions = getInitParameter("otherGzipFileExtensions"); + if (otherGzipExtensions != null) + { + //comma separated list + StringTokenizer tok = new StringTokenizer(otherGzipExtensions, ",", false); + while (tok.hasMoreTokens()) + { + String s = tok.nextToken().trim(); + gzipEquivalentFileExtensions.add((s.charAt(0) == '.' ? s : "." + s)); + } + } + else + { + // .svgz files are gzipped svg files and must be served with Content-Encoding:gzip + gzipEquivalentFileExtensions.add(".svgz"); + } + _resourceService.setGzipEquivalentFileExtensions(gzipEquivalentFileExtensions); + + _pathInfoOnly = getInitBoolean("pathInfoOnly", true); + + if (LOG.isDebugEnabled()) + { + LOG.debug(" .baseResource = {}", baseResource); + LOG.debug(" .resourceService = {}", _resourceService); + LOG.debug(" .welcomeServletMode = {}", _welcomeServletMode); + } + } + + private static ByteBufferPool getByteBufferPool(ContextHandler contextHandler) + { + if (contextHandler == null) + return ByteBufferPool.NON_POOLING; + Server server = contextHandler.getServer(); + if (server == null) + return ByteBufferPool.NON_POOLING; + return server.getByteBufferPool(); + } + + private String getInitParameter(String name, String... deprecated) + { + String value = getInitParameter(name); + if (value != null) + return value; + + for (String d : deprecated) + { + value = getInitParameter(d); + if (value != null) + { + LOG.warn("Deprecated {} used instead of {}", d, name); + return value; + } + } + + return null; + } + + private List parsePrecompressedFormats(String precompressed, Boolean gzip, List dft) + { + if (precompressed == null && gzip == null) + { + return dft; + } + List ret = new ArrayList<>(); + if (precompressed != null && precompressed.indexOf('=') > 0) + { + for (String pair : precompressed.split(",")) + { + String[] setting = pair.split("="); + String encoding = setting[0].trim(); + String extension = setting[1].trim(); + ret.add(new CompressedContentFormat(encoding, extension)); + if (gzip == Boolean.TRUE && !ret.contains(CompressedContentFormat.GZIP)) + ret.add(CompressedContentFormat.GZIP); + } + } + else if (precompressed != null) + { + if (Boolean.parseBoolean(precompressed)) + { + ret.add(CompressedContentFormat.BR); + ret.add(CompressedContentFormat.GZIP); + } + } + else if (gzip == Boolean.TRUE) + { + // gzip handling is for backwards compatibility with older Jetty + ret.add(CompressedContentFormat.GZIP); + } + return ret; + } + + private Boolean getInitBoolean(String name) + { + String value = getInitParameter(name); + if (value == null || value.isEmpty()) + return null; + return (value.startsWith("t") || + value.startsWith("T") || + value.startsWith("y") || + value.startsWith("Y") || + value.startsWith("1")); + } + + private boolean getInitBoolean(String name, boolean dft) + { + return Optional.ofNullable(getInitBoolean(name)).orElse(dft); + } + + private int getInitInt(String name, int dft) + { + String value = getInitParameter(name); + if (value != null && !value.isEmpty()) + return Integer.parseInt(value); + return dft; + } + + protected ServletContextHandler initContextHandler(ServletContext servletContext) + { + if (servletContext instanceof ServletContextHandler.ServletContextApi api) + return api.getContext().getServletContextHandler(); + + Context context = ContextHandler.getCurrentContext(); + if (context instanceof ContextHandler.ScopedContext scopedContext) + return scopedContext.getContextHandler(); + + throw new IllegalArgumentException("The servletContext " + servletContext + " " + + servletContext.getClass().getName() + " is not " + ContextHandler.ScopedContext.class.getName()); + } + + @Override + protected void doGet(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException + { + boolean included = httpServletRequest.getDispatcherType() == DispatcherType.INCLUDE; + String encodedPathInContext = getEncodedPathInContext(httpServletRequest, included); + + if (LOG.isDebugEnabled()) + LOG.debug("doGet(hsReq={}, hsResp={}) pathInContext={}, included={}", httpServletRequest, httpServletResponse, encodedPathInContext, included); + + try + { + HttpContent content = _resourceService.getContent(encodedPathInContext, ServletContextRequest.getServletContextRequest(httpServletRequest)); + if (LOG.isDebugEnabled()) + LOG.debug("content = {}", content); + + if (content == null || Resources.missing(content.getResource())) + { + doNotFound(httpServletRequest, httpServletResponse, encodedPathInContext); + } + else + { + // lookup the core request and response as wrapped by the ServletContextHandler + ServletContextRequest servletContextRequest = ServletContextRequest.getServletContextRequest(httpServletRequest); + ServletContextResponse servletContextResponse = servletContextRequest.getServletContextResponse(); + ServletChannel servletChannel = servletContextRequest.getServletChannel(); + + // If the servlet request has not been wrapped, + // we can use the core request directly, + // otherwise wrap the servlet request as a core request + Request coreRequest = httpServletRequest instanceof ServletApiRequest + ? servletChannel.getRequest() + : ServletCoreRequest.wrap(httpServletRequest); + + // If the servlet response has been wrapped and has been written to, + // then the servlet response must be wrapped as a core response + // otherwise we can use the core response directly. + boolean useServletResponse = !(httpServletResponse instanceof ServletApiResponse) || servletContextResponse.isWritingOrStreaming(); + Response coreResponse = useServletResponse + ? new ServletCoreResponse(coreRequest, httpServletResponse, included) + : servletChannel.getResponse(); + + // If the core response is already committed then do nothing more + if (coreResponse.isCommitted()) + { + if (LOG.isDebugEnabled()) + LOG.debug("Response already committed for {}", coreRequest.getHttpURI()); + return; + } + + // Get the content length before we may wrap the content + long contentLength = content.getContentLengthValue(); + + // Servlet Filters could be interacting with the Response already. + if (useServletResponse) + content = new UnknownLengthHttpContent(content); + + // The character encoding may be forced + String characterEncoding = servletContextResponse.getRawCharacterEncoding(); + if (characterEncoding != null) + content = new ForcedCharacterEncodingHttpContent(content, characterEncoding); + + // If async is supported and the unwrapped content is larger than an output buffer + if (httpServletRequest.isAsyncSupported() && + (contentLength < 0 || contentLength > coreRequest.getConnectionMetaData().getHttpConfiguration().getOutputBufferSize())) + { + // send the content asynchronously + AsyncContext asyncContext = httpServletRequest.startAsync(); + Callback callback = new AsyncContextCallback(asyncContext, httpServletResponse); + _resourceService.doGet(coreRequest, coreResponse, callback, content); + } + else + { + // send the content blocking + try (Blocker.Callback callback = Blocker.callback()) + { + _resourceService.doGet(coreRequest, coreResponse, callback, content); + callback.block(); + } + catch (Exception e) + { + throw new ServletException(e); + } + } + } + } + catch (InvalidPathException e) + { + if (LOG.isDebugEnabled()) + LOG.debug("InvalidPathException for pathInContext: {}", encodedPathInContext, e); + if (included) + throw new FileNotFoundException(encodedPathInContext); + httpServletResponse.setStatus(404); + } + } + + protected String getEncodedPathInContext(HttpServletRequest request, boolean included) + { + HttpServletMapping mapping = request.getHttpServletMapping(); + if (included) + { + if (request.getAttribute(Dispatcher.INCLUDE_MAPPING) instanceof HttpServletMapping httpServletMapping) + { + mapping = httpServletMapping; + } + else + { + // must be an include of a named dispatcher. Just use the whole URI + return URIUtil.encodePath(URIUtil.addPaths(request.getServletPath(), request.getPathInfo())); + } + } + + return switch (mapping.getMappingMatch()) + { + case CONTEXT_ROOT -> "/"; + case DEFAULT, EXTENSION, EXACT -> + { + if (included) + yield URIUtil.encodePath((String)request.getAttribute(Dispatcher.INCLUDE_SERVLET_PATH)); + else if (request instanceof ServletApiRequest apiRequest) + // Strip the context path from the canonically encoded path, so no need to re-encode (and mess up %2F etc.) + yield Context.getPathInContext(request.getContextPath(), apiRequest.getRequest().getHttpURI().getCanonicalPath()); + else + yield URIUtil.encodePath(request.getServletPath()); + } + case PATH -> + { + if (_pathInfoOnly) + { + if (included) + yield URIUtil.encodePath((String)request.getAttribute(Dispatcher.INCLUDE_PATH_INFO)); + else + yield URIUtil.encodePath(request.getPathInfo()); + } + else + { + if (included) + yield URIUtil.encodePath(URIUtil.addPaths((String)request.getAttribute(Dispatcher.INCLUDE_SERVLET_PATH), (String)request.getAttribute(Dispatcher.INCLUDE_PATH_INFO))); + else if (request instanceof ServletApiRequest apiRequest) + // Strip the context path from the canonically encoded path, so no need to re-encode (and mess up %2F etc.) + yield Context.getPathInContext(request.getContextPath(), apiRequest.getRequest().getHttpURI().getCanonicalPath()); + else + yield URIUtil.encodePath(URIUtil.addPaths(request.getServletPath(), request.getPathInfo())); + } + } + }; + } + + @Override + protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException + { + if (LOG.isDebugEnabled()) + LOG.debug("doHead(req={}, resp={}) (calling doGet())", req, resp); + doGet(req, resp); + } + + @Override + protected void doTrace(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException + { + // Always return 405: Method Not Allowed for DefaultServlet + resp.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } + + @Override + protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException + { + // override to eliminate TRACE that the default HttpServlet impl adds + resp.setHeader("Allow", "GET, HEAD, OPTIONS"); + } + + protected void doNotFound(HttpServletRequest request, HttpServletResponse response, String encodedPathInContext) throws IOException + { + if (request.getDispatcherType() == DispatcherType.INCLUDE) + { + /* https://github.com/jakartaee/servlet/blob/6.0.0-RELEASE/spec/src/main/asciidoc/servlet-spec-body.adoc#93-the-include-method + * 9.3 - If the default servlet is the target of a RequestDispatch.include() and the requested + * resource does not exist, then the default servlet MUST throw FileNotFoundException. + * If the exception isn’t caught and handled, and the response + * hasn’t been committed, the status code MUST be set to 500. + */ + throw new FileNotFoundException(encodedPathInContext); + } + + // no content + response.sendError(404); + } + + private class ServletResourceService extends ResourceService implements ResourceService.WelcomeFactory + { + private final ServletContextHandler _servletContextHandler; + + private ServletResourceService(ServletContextHandler servletContextHandler) + { + _servletContextHandler = servletContextHandler; + } + + @Override + public String getWelcomeTarget(HttpContent content, Request coreRequest) + { + String[] welcomes = _servletContextHandler.getWelcomeFiles(); + if (welcomes == null) + return null; + String pathInContext = Request.getPathInContext(coreRequest); + String welcomeTarget = null; + Resource base = content.getResource(); + if (Resources.isReadableDirectory(base)) + { + for (String welcome : welcomes) + { + String welcomeInContext = URIUtil.addPaths(pathInContext, welcome); + + // If the welcome resource is a file, it has + // precedence over resources served by Servlets. + Resource welcomePath = content.getResource().resolve(welcome); + if (Resources.isReadableFile(welcomePath)) + return welcomeInContext; + + // Check whether a Servlet may serve the welcome resource. + if (_welcomeServletMode != WelcomeServletMode.NONE && welcomeTarget == null) + { + ServletHandler.MappedServlet entry = _servletContextHandler.getServletHandler().getMappedServlet(welcomeInContext); + // Is there a different Servlet that may serve the welcome resource? + if (entry != null && entry.getServletHolder().getServletInstance() != ResourceServlet.this) + { + if (_welcomeServletMode == WelcomeServletMode.MATCH || entry.getPathSpec().getDeclaration().equals(welcomeInContext)) + { + welcomeTarget = welcomeInContext; + // Do not break the loop, because we want to try other welcome resources + // that may be files and take precedence over Servlet welcome resources. + } + } + } + } + } + return welcomeTarget; + } + + @Override + protected void serveWelcome(Request request, Response response, Callback callback, String welcomeTarget) throws IOException + { + HttpServletRequest servletRequest = getServletRequest(request); + HttpServletResponse servletResponse = getServletResponse(response); + + boolean included = isIncluded(servletRequest); + + RequestDispatcher dispatcher = servletRequest.getServletContext().getRequestDispatcher(welcomeTarget); + if (dispatcher == null) + { + // We know that the welcome target exists and can be served. + Response.writeError(request, response, callback, HttpStatus.INTERNAL_SERVER_ERROR_500); + return; + } + + try + { + if (included) + { + dispatcher.include(servletRequest, servletResponse); + } + else + { + servletRequest.setAttribute("org.eclipse.jetty.server.welcome", welcomeTarget); + dispatcher.forward(servletRequest, servletResponse); + } + callback.succeeded(); + } + catch (ServletException e) + { + callback.failed(e); + } + } + + @Override + protected void rehandleWelcome(Request request, Response response, Callback callback, String welcomeTarget) throws IOException + { + serveWelcome(request, response, callback, welcomeTarget); + } + + @Override + protected void writeHttpError(Request coreRequest, Response coreResponse, Callback callback, int statusCode) + { + if (LOG.isDebugEnabled()) + LOG.debug("writeHttpError(coreRequest={}, coreResponse={}, callback={}, statusCode={})", coreRequest, coreResponse, callback, statusCode); + writeHttpError(coreRequest, coreResponse, callback, statusCode, null, null); + } + + @Override + protected void writeHttpError(Request coreRequest, Response coreResponse, Callback callback, Throwable cause) + { + if (LOG.isDebugEnabled()) + LOG.debug("writeHttpError(coreRequest={}, coreResponse={}, callback={}, cause={})", coreRequest, coreResponse, callback, cause, cause); + + int statusCode = HttpStatus.INTERNAL_SERVER_ERROR_500; + String reason = null; + if (cause instanceof HttpException httpException) + { + statusCode = httpException.getCode(); + reason = httpException.getReason(); + } + writeHttpError(coreRequest, coreResponse, callback, statusCode, reason, cause); + } + + @Override + protected void writeHttpError(Request coreRequest, Response coreResponse, Callback callback, int statusCode, String reason, Throwable cause) + { + if (LOG.isDebugEnabled()) + LOG.debug("writeHttpError(coreRequest={}, coreResponse={}, callback={}, statusCode={}, reason={}, cause={})", coreRequest, coreResponse, callback, statusCode, reason, cause, cause); + HttpServletRequest request = getServletRequest(coreRequest); + HttpServletResponse response = getServletResponse(coreResponse); + try + { + if (isIncluded(request)) + return; + if (cause != null) + request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, cause); + response.sendError(statusCode, reason); + } + catch (IOException e) + { + // TODO: Need a better exception? + throw new RuntimeException(e); + } + finally + { + callback.succeeded(); + } + } + + @Override + protected boolean passConditionalHeaders(Request request, Response response, HttpContent content, Callback callback) throws IOException + { + boolean included = isIncluded(getServletRequest(request)); + if (included) + return false; + return super.passConditionalHeaders(request, response, content, callback); + } + + private HttpServletRequest getServletRequest(Request request) + { + ServletCoreRequest servletCoreRequest = Request.as(request, ServletCoreRequest.class); + if (servletCoreRequest != null) + return servletCoreRequest.getServletRequest(); + + ServletContextRequest servletContextRequest = Request.as(request, ServletContextRequest.class); + if (servletContextRequest != null) + return servletContextRequest.getServletApiRequest(); + + throw new IllegalStateException("instanceof " + request.getClass()); + } + + private HttpServletResponse getServletResponse(Response response) + { + ServletCoreResponse servletCoreResponse = Response.as(response, ServletCoreResponse.class); + if (servletCoreResponse != null) + return servletCoreResponse.getServletResponse(); + + ServletContextResponse servletContextResponse = Response.as(response, ServletContextResponse.class); + if (servletContextResponse != null) + return servletContextResponse.getServletApiResponse(); + + throw new IllegalStateException("instanceof " + response.getClass()); + } + } + + static String getIncludedPathInContext(HttpServletRequest request, String includedServletPath) + { + String pathInfo = (String)request.getAttribute(RequestDispatcher.INCLUDE_PATH_INFO); + return URIUtil.addPaths(includedServletPath, pathInfo); + } + + private static boolean isIncluded(HttpServletRequest request) + { + return request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) != null; + } + + /** + * Wrap an existing HttpContent with one that takes has an unknown/unspecified length. + */ + private static class UnknownLengthHttpContent extends HttpContent.Wrapper + { + public UnknownLengthHttpContent(HttpContent content) + { + super(content); + } + + @Override + public HttpField getContentLength() + { + return null; + } + + @Override + public long getContentLengthValue() + { + return -1; + } + } + + private static class ForcedCharacterEncodingHttpContent extends HttpContent.Wrapper + { + private final String characterEncoding; + private final String contentType; + + public ForcedCharacterEncodingHttpContent(HttpContent content, String characterEncoding) + { + super(Objects.requireNonNull(content)); + this.characterEncoding = characterEncoding; + if (content.getContentTypeValue() == null || content.getResource().isDirectory()) + { + this.contentType = null; + } + else + { + String mimeType = content.getContentTypeValue(); + int idx = mimeType.indexOf(";charset"); + if (idx >= 0) + mimeType = mimeType.substring(0, idx); + this.contentType = mimeType + ";charset=" + characterEncoding; + } + } + + @Override + public HttpField getContentType() + { + return new HttpField(HttpHeader.CONTENT_TYPE, this.contentType); + } + + @Override + public String getContentTypeValue() + { + return this.contentType; + } + + @Override + public String getCharacterEncoding() + { + return this.characterEncoding; + } + } + + /** + *

The different modes a welcome resource may be served by a Servlet.

+ */ + private enum WelcomeServletMode + { + /** + *

Welcome targets are not served by Servlets.

+ *

The welcome target must exist as a file on the filesystem.

+ */ + NONE, + /** + *

Welcome target that exist as files on the filesystem are + * served, otherwise a matching Servlet may serve the welcome target.

+ */ + MATCH, + /** + *

Welcome target that exist as files on the filesystem are + * served, otherwise an exact matching Servlet may serve the welcome target.

+ */ + EXACT + } + + private static class AsyncContextCallback implements Callback + { + private final AsyncContext _asyncContext; + private final HttpServletResponse _response; + + private AsyncContextCallback(AsyncContext asyncContext, HttpServletResponse response) + { + _asyncContext = asyncContext; + _response = response; + } + + @Override + public void succeeded() + { + _asyncContext.complete(); + } + + @Override + public void failed(Throwable x) + { + try + { + if (LOG.isDebugEnabled()) + LOG.debug("AsyncContextCallback failed {}", _asyncContext, x); + // It is known that this callback is only failed if the response is already committed, + // thus we can only abort the response here. + _response.sendError(-1); + } + catch (IOException e) + { + ExceptionUtil.addSuppressedIfNotAssociated(x, e); + } + finally + { + _asyncContext.complete(); + } + if (LOG.isDebugEnabled()) + LOG.debug("Async get failed", x); + } + } +} diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java index 0a99cb9f30b..4d7083accfc 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletApiRequest.java @@ -1433,6 +1433,12 @@ public class ServletApiRequest implements HttpServletRequest return trailersMap; } + @Override + public String toString() + { + return "%s@%x{%s}".formatted(getClass().getSimpleName(), hashCode(), _servletContextRequest); + } + static class AmbiguousURI extends ServletApiRequest { private final String msg; diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletCoreRequest.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletCoreRequest.java index 37d8b71efd7..fe0b6ffad1c 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletCoreRequest.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ServletCoreRequest.java @@ -100,7 +100,7 @@ public class ServletCoreRequest implements Request .authority(request.getServerName(), request.getServerPort()); if (included) - builder.path(addEncodedPaths(request.getContextPath(), encodePath(DefaultServlet.getIncludedPathInContext(request, includedServletPath, false)))); + builder.path(addEncodedPaths(request.getContextPath(), encodePath(ResourceServlet.getIncludedPathInContext(request, includedServletPath)))); else if (request.getDispatcherType() != DispatcherType.REQUEST) builder.path(addEncodedPaths(request.getContextPath(), encodePath(URIUtil.addPaths(_servletRequest.getServletPath(), _servletRequest.getPathInfo())))); else diff --git a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/DefaultServletTest.java b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/DefaultServletTest.java index c8b3e4c889c..d53f04b9df9 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/DefaultServletTest.java +++ b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/DefaultServletTest.java @@ -38,6 +38,7 @@ import jakarta.servlet.DispatcherType; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.FilterConfig; +import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; @@ -52,7 +53,6 @@ import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.http.UriCompliance; import org.eclipse.jetty.http.content.ResourceHttpContent; -import org.eclipse.jetty.http.content.ResourceHttpContentFactory; import org.eclipse.jetty.io.ByteBufferPool; import org.eclipse.jetty.io.IOResources; import org.eclipse.jetty.logging.StacklessLogging; @@ -1383,7 +1383,7 @@ public class DefaultServletTest FS.ensureDirExists(altRoot); ServletHolder defholder = context.addServlet(DefaultServlet.class, "/alt/*"); - defholder.setInitParameter("resourceBase", altRoot.toUri().toASCIIString()); + defholder.setInitParameter("baseResource", altRoot.toUri().toASCIIString()); defholder.setInitParameter("dirAllowed", "false"); defholder.setInitParameter("redirectWelcome", "false"); defholder.setInitParameter("welcomeServlets", "true"); @@ -1394,7 +1394,8 @@ public class DefaultServletTest protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String includeTarget = req.getParameter("includeTarget"); - req.getRequestDispatcher(includeTarget).include(req, resp); + RequestDispatcher requestDispatcher = req.getRequestDispatcher(includeTarget); + requestDispatcher.include(req, resp); } }); context.addServlet(gwholder, "/gateway"); @@ -2001,10 +2002,9 @@ public class DefaultServletTest assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); assertThat(response.getContent(), containsString("

Hello World

")); - ResourceHttpContentFactory factory = (ResourceHttpContentFactory)context.getServletContext().getAttribute("resourceCache"); - /* TODO: fix after HttpContent changes. + ResourceHttpContentFactory factory = (ResourceHttpContentFactory)context.getServletContext().getAttribute("resourceCache"); HttpContent content = factory.getContent("/index.html", 200); ByteBuffer buffer = content.getDirectBuffer(); assertThat("Buffer is direct", buffer.isDirect(), is(true)); @@ -3425,22 +3425,17 @@ public class DefaultServletTest response.setCharacterEncoding("utf-8"); chain.doFilter(request, response); } - - @Override - public void destroy() - { - } } @Test - public void testPathInfoOnly() throws Exception + public void testNotPathInfoOnly() throws Exception { ServletContextHandler context = new ServletContextHandler("/c1", ServletContextHandler.NO_SESSIONS); context.setWelcomeFiles(new String[]{"index.y", "index.x"}); ServletHolder indexServlet = new ServletHolder("index-servlet", new HttpServlet() { @Override - protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException + protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/plain"); resp.setCharacterEncoding("UTF-8"); @@ -3456,27 +3451,27 @@ public class DefaultServletTest context.getServletHandler().addServlet(indexServlet); context.getServletHandler().addServletMapping(indexMapping); - Path pathTest = MavenTestingUtils.getTestResourcePath("pathTest"); + Path docroot = MavenTestingUtils.getTestResourcePath("docroot"); - Path defaultDir = pathTest.resolve("default"); ServletHolder slashHolder = new ServletHolder("default", new DefaultServlet()); slashHolder.setInitParameter("redirectWelcome", "false"); slashHolder.setInitParameter("welcomeServlets", "true"); - slashHolder.setInitParameter("baseResource", defaultDir.toAbsolutePath().toString()); + slashHolder.setInitParameter("baseResource", docroot.toAbsolutePath().toString()); context.addServlet(slashHolder, "/"); - Path rDir = pathTest.resolve("rdir"); - ServletHolder rHolder = new ServletHolder("rdefault", new DefaultServlet()); + Path altroot = MavenTestingUtils.getTestResourcePath("altroot"); + ServletHolder rHolder = new ServletHolder("alt", new DefaultServlet()); rHolder.setInitParameter("redirectWelcome", "false"); rHolder.setInitParameter("welcomeServlets", "true"); - rHolder.setInitParameter("baseResource", rDir.toAbsolutePath().toString()); - context.addServlet(rHolder, "/r/*"); + rHolder.setInitParameter("pathInfoOnly", "false"); + rHolder.setInitParameter("baseResource", altroot.toAbsolutePath().toString()); + context.addServlet(rHolder, "/all/*"); server.stop(); server.setHandler(context); server.start(); String rawRequest = """ - GET /c1/r/ HTTP/1.1\r + GET /c1/all/index.html HTTP/1.1\r Host: localhost\r Connection: close\r \r @@ -3484,7 +3479,7 @@ public class DefaultServletTest String rawResponse = connector.getResponse(rawRequest); HttpTester.Response response = HttpTester.parseResponse(rawResponse); - assertThat(response.getContent(), containsString("testPathInfoOnly-OK")); + assertThat(response.getContent(), containsString("this is alternate index content")); } @Test @@ -3652,11 +3647,6 @@ public class DefaultServletTest response.setCharacterEncoding("utf-8"); chain.doFilter(request, response); } - - @Override - public void destroy() - { - } } private boolean deleteFile(Path file) throws IOException diff --git a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ResourceServletTest.java b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ResourceServletTest.java new file mode 100644 index 00000000000..2bd18bced7d --- /dev/null +++ b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ResourceServletTest.java @@ -0,0 +1,3930 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.ee10.servlet; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.CompressedContentFormat; +import org.eclipse.jetty.http.DateGenerator; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.http.UriCompliance; +import org.eclipse.jetty.http.content.ResourceHttpContent; +import org.eclipse.jetty.http.content.ResourceHttpContentFactory; +import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.IOResources; +import org.eclipse.jetty.logging.StacklessLogging; +import org.eclipse.jetty.server.AllowedResourceAliasChecker; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.LocalConnector; +import org.eclipse.jetty.server.ResourceService; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.SymlinkAllowedResourceAliasChecker; +import org.eclipse.jetty.toolchain.test.FS; +import org.eclipse.jetty.toolchain.test.MavenPaths; +import org.eclipse.jetty.toolchain.test.MavenTestingUtils; +import org.eclipse.jetty.toolchain.test.jupiter.WorkDir; +import org.eclipse.jetty.toolchain.test.jupiter.WorkDirExtension; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.ResourceFactory; +import org.eclipse.jetty.util.resource.URLResourceFactory; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.eclipse.jetty.http.tools.matchers.HttpFieldsMatchers.containsHeader; +import static org.eclipse.jetty.http.tools.matchers.HttpFieldsMatchers.containsHeaderValue; +import static org.eclipse.jetty.http.tools.matchers.HttpFieldsMatchers.headerValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@ExtendWith(WorkDirExtension.class) +public class ResourceServletTest +{ + public WorkDir workDir; + + public Path docRoot; + + // The name of the odd-jar used for testing "jar:file://" based resource access. + private static final String ODD_JAR = "jar-resource-odd.jar"; + + private Server server; + private LocalConnector connector; + private ServletContextHandler context; + + @BeforeEach + public void init() throws Exception + { + docRoot = workDir.getEmptyPathDir().resolve("docroot"); + FS.ensureDirExists(docRoot); + + server = new Server(); + + connector = new LocalConnector(server); + connector.getConnectionFactory(HttpConfiguration.ConnectionFactory.class).getHttpConfiguration().setSendServerVersion(false); + Path extraJarResources = MavenPaths.findTestResourceFile(ODD_JAR); + URL[] urls = new URL[]{extraJarResources.toUri().toURL()}; + + ClassLoader parentClassLoader = Thread.currentThread().getContextClassLoader(); + URLClassLoader extraClassLoader = new URLClassLoader(urls, parentClassLoader); + + context = new ServletContextHandler(); + context.setBaseResourceAsPath(docRoot); + context.setContextPath("/context"); + context.setWelcomeFiles(new String[]{"index.html", "index.jsp", "index.htm"}); + context.setClassLoader(extraClassLoader); + + server.setHandler(context); + server.addConnector(connector); + + server.start(); + } + + @AfterEach + public void destroy() throws Exception + { + server.stop(); + server.join(); + } + + public static Stream getTests() + { + return Stream.of( + Arguments.of(null, "file.txt", "/", null, "file.txt"), + Arguments.of(null, "dir/file.txt", "/", null, "dir/file.txt"), + + Arguments.of(null, "file.txt", "/*", null, "file.txt"), + Arguments.of(null, "dir/file.txt", "/*", null, "dir/file.txt"), + + Arguments.of("dir", "dir/file.txt", "/dir/*", null, "dir/file.txt"), + Arguments.of("dir", "dir/file.txt", "/dir/*", true, "dir/file.txt"), + Arguments.of(null, "dir/file.txt", "/dir/*", false, "dir/file.txt"), + Arguments.of(".", "dir/file.txt", "/dir/*", false, "dir/file.txt"), + + Arguments.of(null, "file.txt", "/file.txt", null, "file.txt"), + Arguments.of(null, "dir/file.txt", "/dir/file.txt", null, "dir/file.txt"), + + Arguments.of(null, "file.txt", "*.txt", null, "file.txt"), + Arguments.of(null, "dir/file.txt", "*.txt", null, "dir/file.txt") + ); + } + + @ParameterizedTest + @MethodSource("getTests") + public void testGet(String baseResource, String resourceInContext, String mapping, Boolean pathInfoOnly, String pathInContext) throws Exception + { + Path file = docRoot.resolve(resourceInContext); + Path directory = file.getParent(); + if (!directory.equals(docRoot) && !Files.exists(directory)) + Files.createDirectories(directory); + + ServletHolder servletHolder = context.addServlet(ResourceServlet.class, mapping); + if (StringUtil.isNotBlank(baseResource)) + servletHolder.getInitParameters().put("baseResource", baseResource); + if (pathInfoOnly != null) + servletHolder.getInitParameters().put("pathInfoOnly", String.valueOf(pathInfoOnly)); + + String rawResponse; + HttpTester.Response response; + + rawResponse = connector.getResponse(""" + GET /context/%s HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """.formatted(pathInContext)); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.NOT_FOUND_404)); + + Files.writeString(file, "How now brown cow", UTF_8); + + rawResponse = connector.getResponse(""" + GET /context/%s HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """.formatted(pathInContext)); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); + assertThat(response.toString(), response.getContent(), is("How now brown cow")); + } + + @ParameterizedTest + @MethodSource("getTests") + public void testHead(String baseResource, String resourceInContext, String mapping, Boolean pathInfoOnly, String pathInContext) throws Exception + { + Path file = docRoot.resolve(resourceInContext); + Path directory = file.getParent(); + if (!directory.equals(docRoot) && !Files.exists(directory)) + Files.createDirectories(directory); + + ServletHolder servletHolder = context.addServlet(ResourceServlet.class, mapping); + if (StringUtil.isNotBlank(baseResource)) + servletHolder.getInitParameters().put("baseResource", baseResource); + if (pathInfoOnly != null) + servletHolder.getInitParameters().put("pathInfoOnly", String.valueOf(pathInfoOnly)); + + String rawResponse; + String headResponse; + HttpTester.Response response; + + rawResponse = connector.getResponse(""" + GET /context/%s HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """.formatted(pathInContext)); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.NOT_FOUND_404)); + + headResponse = connector.getResponse(""" + HEAD /context/%s HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """.formatted(pathInContext)); + + assertThat(rawResponse.replaceFirst("Date: .* GMT", "Date: yyyy-mm-dd"), startsWith(headResponse.replaceFirst("Date: .* GMT", "Date: yyyy-mm-dd"))); + + + Files.writeString(file, "How now brown cow", UTF_8); + + rawResponse = connector.getResponse(""" + GET /context/%s HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """.formatted(pathInContext)); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); + assertThat(response.toString(), response.getContent(), is("How now brown cow")); + + headResponse = connector.getResponse(""" + HEAD /context/%s HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """.formatted(pathInContext)); + assertThat(rawResponse.replaceFirst("Date: .* GMT", "Date: yyyy-mm-dd"), startsWith(headResponse.replaceFirst("Date: .* GMT", "Date: yyyy-mm-dd"))); + } + + @Test + public void testPost() throws Exception + { + Path file = docRoot.resolve("file.txt"); + + context.addServlet(ResourceServlet.class, "/"); + + String rawResponse; + HttpTester.Response response; + + rawResponse = connector.getResponse(""" + POST /context/file.txt HTTP/1.1\r + Host: local\r + Connection: close\r + Content-Length: 5\r + \r + abcde + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.METHOD_NOT_ALLOWED_405)); + + Files.writeString(file, "How now brown cow", UTF_8); + + rawResponse = connector.getResponse(""" + POST /context/file.txt HTTP/1.1\r + Host: local\r + Connection: close\r + Content-Length: 5\r + \r + abcde + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.METHOD_NOT_ALLOWED_405)); + } + + @Test + public void testTrace() throws Exception + { + context.addServlet(ResourceServlet.class, "/"); + + String rawResponse; + HttpTester.Response response; + + rawResponse = connector.getResponse(""" + TRACE /context/file.txt HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.METHOD_NOT_ALLOWED_405)); + } + + @Test + public void testOptions() throws Exception + { + context.addServlet(ResourceServlet.class, "/"); + + String rawResponse; + HttpTester.Response response; + + rawResponse = connector.getResponse(""" + OPTIONS /context/ HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); + assertThat(response.get(HttpHeader.ALLOW), is("GET, HEAD, OPTIONS")); + } + + @Test + public void testGetBinaryWithUtfResponseEncoding() throws Exception + { + Path path = docRoot.resolve("keystore.p12"); + byte[] originalBytes; + + try (InputStream is = getClass().getResourceAsStream("/keystore.p12"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + OutputStream fos = Files.newOutputStream(path)) + { + IO.copy(is, baos); + originalBytes = baos.toByteArray(); + fos.write(originalBytes); + } + + context.setDefaultResponseCharacterEncoding("utf-8"); + context.addServlet(ResourceServlet.class, "/"); + + String rawResponse; + HttpTester.Response response; + + rawResponse = connector.getResponse(""" + GET /context/keystore.p12 HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); + byte[] readContentBytes = response.getContentBytes(); + + assertThat(Arrays.equals(readContentBytes, originalBytes), is(true)); + } + + @Test + public void testGetPercent2F() throws Exception + { + connector.getConnectionFactory(HttpConfiguration.ConnectionFactory.class).getHttpConfiguration().setUriCompliance(UriCompliance.UNSAFE); + + Path file = docRoot.resolve("file.txt"); + Files.writeString(file, "How now brown cow", UTF_8); + + context.addServlet(ResourceServlet.class, "/"); + + String rawResponse; + HttpTester.Response response; + + // Access normally, in root of context + + rawResponse = connector.getResponse(""" + GET /context/file.txt HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); + assertThat(response.toString(), response.getContent(), is("How now brown cow")); + + // Attempt access using "%2F" instead of "/", should be a 404 (mainly because context isn't found) + + rawResponse = connector.getResponse(""" + GET /context%2Ffile.txt HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.NOT_FOUND_404)); + + Path dir = docRoot.resolve("dirFoo"); + Files.createDirectory(dir); + Path other = dir.resolve("other.txt"); + Files.writeString(other, "In a while", UTF_8); + + // Attempt access of content in sub-dir of context, using "%2F" instead of "/", should be a 404 + // as neither getServletPath and getPathInfo are used and thus they don't throw. + rawResponse = connector.getResponse(""" + GET /context/dirFoo%2Fother.txt HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.NOT_FOUND_404)); + } + + @Test + public void testListingWithSession() throws Exception + { + ServletHolder holder = context.addServlet(ResourceServlet.class, "/"); + holder.setInitParameter("dirAllowed", "true"); + holder.setInitParameter("redirectWelcome", "false"); + holder.setInitParameter("gzip", "false"); + + /* create some content in the docroot */ + FS.ensureDirExists(docRoot.resolve("one")); + FS.ensureDirExists(docRoot.resolve("two")); + FS.ensureDirExists(docRoot.resolve("three")); + + String rawResponse = connector.getResponse(""" + GET /context/;JSESSIONID=1234567890 HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + assertThat(response.getStatus(), is(200)); + + String body = response.getContent(); + + assertThat(body, containsString("/one/;JSESSIONID=1234567890")); + assertThat(body, containsString("/two/;JSESSIONID=1234567890")); + assertThat(body, containsString("/three/;JSESSIONID=1234567890")); + + assertThat(body, not(containsString(" HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """; + String rawResponse = connector.getResponse(req1); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + + String body = response.getContent(); + assertThat(body, not(containsString("