diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/TryPathsHandler.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/TryPathsHandler.java index a33b08e63de2..476758311991 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/TryPathsHandler.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/handler/TryPathsHandler.java @@ -16,36 +16,110 @@ import java.util.List; import org.eclipse.jetty.http.HttpURI; -import org.eclipse.jetty.server.Context; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.util.URIUtil; -import org.eclipse.jetty.util.resource.Resource; /** *

Inspired by nginx's {@code try_files} functionality.

- *

This handler can be configured with a list of URI paths. - * The special token {@code $path} represents the current request URI - * path (the portion after the context path).

+ * + *

This handler can be configured with a list of rewrite URI paths. + * The special token {@code $path} represents the current request + * {@code pathInContext} (the portion after the context path).

+ * *

Typical example of how this handler can be configured is the following:

*
{@code
- * TryPathsHandler tryPaths = new TryPathsHandler();
- * tryPaths.setPaths("/maintenance.html", "$path", "/index.php?p=$path");
+ * TryPathsHandler tryPathsHandler = new TryPathsHandler();
+ * tryPathsHandler.setPaths("/maintenance.html", "$path", "/index.php?p=$path");
+ *
+ * PathMappingsHandler pathMappingsHandler = new PathMappingsHandler();
+ * tryPathsHandler.setHandler(pathMappingsHandler);
+ *
+ * pathMappingsHandler.addMapping(new ServletPathSpec("*.php"), new PHPHandler());
+ * pathMappingsHandler.addMapping(new ServletPathSpec("/"), new ResourceHandler());
  * }
- *

For a request such as {@code /context/path/to/resource.ext}, this - * handler will try to serve the {@code /maintenance.html} file if it finds - * it; failing that, it will try to serve the {@code /path/to/resource.ext} - * file if it finds it; failing that it will forward the request to - * {@code /index.php?p=/path/to/resource.ext} to the next handler.

- *

The last URI path specified in the list is therefore the "fallback" to - * which the request is forwarded to in case no previous files can be found.

- *

The file paths are resolved against {@link Context#getBaseResource()} - * to make sure that only files visible to the application are served.

+ * + *

For a request such as {@code /context/path/to/resource.ext}:

+ * + * + *

The original path and query may be stored as request attributes, + * under the names specified by {@link #setOriginalPathAttribute(String)} + * and {@link #setOriginalQueryAttribute(String)}.

*/ public class TryPathsHandler extends Handler.Wrapper { + private String originalPathAttribute; + private String originalQueryAttribute; private List paths; + /** + * @return the attribute name of the original request path + */ + public String getOriginalPathAttribute() + { + return originalPathAttribute; + } + + /** + *

Sets the request attribute name to use to + * retrieve the original request path.

+ * + * @param originalPathAttribute the attribute name of the original + * request path + */ + public void setOriginalPathAttribute(String originalPathAttribute) + { + this.originalPathAttribute = originalPathAttribute; + } + + /** + * @return the attribute name of the original request query + */ + public String getOriginalQueryAttribute() + { + return originalQueryAttribute; + } + + /** + *

Sets the request attribute name to use to + * retrieve the original request query.

+ * + * @param originalQueryAttribute the attribute name of the original + * request query + */ + public void setOriginalQueryAttribute(String originalQueryAttribute) + { + this.originalQueryAttribute = originalQueryAttribute; + } + + /** + * @return the rewrite URI paths + */ + public List getPaths() + { + return paths; + } + + /** + *

Sets a list of rewrite URI paths.

+ *

The special token {@code $path} represents the current request + * {@code pathInContext} (the portion after the context path).

+ * + * @param paths the rewrite URI paths + */ public void setPaths(List paths) { this.paths = paths; @@ -54,27 +128,15 @@ public void setPaths(List paths) @Override public Request.Processor handle(Request request) throws Exception { - String interpolated = interpolate(request, "$path"); - Resource rootResource = request.getContext().getBaseResource(); - if (rootResource != null) + for (String path : paths) { - for (String path : paths) - { - interpolated = interpolate(request, path); - Resource resource = rootResource.resolve(interpolated); - if (resource != null && resource.exists()) - break; - } + String interpolated = interpolate(request, path); + Request.WrapperProcessor result = new Request.WrapperProcessor(new TryPathsRequest(request, interpolated)); + Request.Processor childProcessor = super.handle(result); + if (childProcessor != null) + return result.wrapProcessor(childProcessor); } - Request.WrapperProcessor result = new Request.WrapperProcessor(new TryPathsRequest(request, interpolated)); - return result.wrapProcessor(super.handle(result)); - } - - private Request.Processor fallback(Request request) throws Exception - { - String fallback = paths.isEmpty() ? "$path" : paths.get(paths.size() - 1); - String interpolated = interpolate(request, fallback); - return super.handle(new TryPathsRequest(request, interpolated)); + return null; } private String interpolate(Request request, String value) @@ -83,14 +145,37 @@ private String interpolate(Request request, String value) return value.replace("$path", path); } - private static class TryPathsRequest extends Request.Wrapper + private class TryPathsRequest extends Request.Wrapper { private final HttpURI _uri; - public TryPathsRequest(Request wrapped, String pathInContext) + public TryPathsRequest(Request wrapped, String newPathQuery) { super(wrapped); - _uri = Request.newHttpURIFrom(wrapped, URIUtil.canonicalPath(pathInContext)); + + HttpURI originalURI = wrapped.getHttpURI(); + + String originalPathAttribute = getOriginalPathAttribute(); + if (originalPathAttribute != null) + setAttribute(originalPathAttribute, Request.getPathInContext(wrapped)); + String originalQueryAttribute = getOriginalQueryAttribute(); + if (originalQueryAttribute != null) + setAttribute(originalQueryAttribute, originalURI.getQuery()); + + String originalContextPath = Request.getContextPath(wrapped); + HttpURI.Mutable rewrittenURI = HttpURI.build(originalURI); + int queryIdx = newPathQuery.indexOf('?'); + if (queryIdx >= 0) + { + String path = newPathQuery.substring(0, queryIdx); + rewrittenURI.path(URIUtil.addPaths(originalContextPath, path)); + rewrittenURI.query(newPathQuery.substring(queryIdx + 1)); + } + else + { + rewrittenURI.path(URIUtil.addPaths(originalContextPath, newPathQuery)); + } + _uri = rewrittenURI.asImmutable(); } @Override diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/TryPathsHandlerTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/TryPathsHandlerTest.java index ea1d98f160cd..5453689d85f0 100644 --- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/TryPathsHandlerTest.java +++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/handler/TryPathsHandlerTest.java @@ -22,16 +22,18 @@ import java.util.List; import javax.net.ssl.SSLSocket; +import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.http.HttpURI; +import org.eclipse.jetty.http.pathmap.ServletPathSpec; +import org.eclipse.jetty.io.Content; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.toolchain.test.FS; -import org.eclipse.jetty.toolchain.test.MavenTestingUtils; +import org.eclipse.jetty.toolchain.test.jupiter.WorkDir; import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.component.LifeCycle; @@ -41,18 +43,20 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class TryPathsHandlerTest { + public WorkDir workDir; + private static final String CONTEXT_PATH = "/ctx"; private Server server; private SslContextFactory.Server sslContextFactory; private ServerConnector connector; private ServerConnector sslConnector; private Path rootPath; - private String contextPath; private void start(List paths, Handler handler) throws Exception { @@ -66,22 +70,18 @@ private void start(List paths, Handler handler) throws Exception sslConnector = new ServerConnector(server, 1, 1, sslContextFactory); server.addConnector(sslConnector); - contextPath = "/ctx"; - ContextHandler context = new ContextHandler(contextPath); - rootPath = Files.createDirectories(MavenTestingUtils.getTargetTestingPath(getClass().getSimpleName())); - FS.cleanDirectory(rootPath); + ContextHandler context = new ContextHandler(CONTEXT_PATH); + rootPath = workDir.getEmptyPathDir(); context.setBaseResourceAsPath(rootPath); server.setHandler(context); TryPathsHandler tryPaths = new TryPathsHandler(); context.setHandler(tryPaths); - tryPaths.setPaths(paths); - - ResourceHandler resourceHandler = new ResourceHandler(); - tryPaths.setHandler(resourceHandler); - resourceHandler.setHandler(handler); + tryPaths.setPaths(paths); + tryPaths.setHandler(handler); + server.setDumpAfterStart(true); server.start(); } @@ -94,47 +94,155 @@ public void dispose() @Test public void testTryPaths() throws Exception { - start(List.of("/maintenance.txt", "$path", "/forward?p=$path"), new Handler.Processor() + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setDirAllowed(false); + resourceHandler.setHandler(new Handler.Abstract() { @Override - public void process(Request request, Response response, Callback callback) + public Request.Processor handle(Request request) { - assertThat(Request.getPathInContext(request), equalTo("/forward%3Fp=/last")); - response.setStatus(HttpStatus.NO_CONTENT_204); - callback.succeeded(); + if (!Request.getPathInContext(request).startsWith("/forward")) + return null; + + return new Handler.Processor() + { + public void process(Request request, Response response, Callback callback) + { + assertThat(Request.getPathInContext(request), equalTo("/forward")); + assertThat(request.getHttpURI().getQuery(), equalTo("p=/last")); + response.setStatus(HttpStatus.NO_CONTENT_204); + callback.succeeded(); + } + }; + } + }); + + start(List.of("/maintenance.txt", "$path", "/forward?p=$path"), resourceHandler); + + try (SocketChannel channel = SocketChannel.open()) + { + channel.connect(new InetSocketAddress("localhost", connector.getLocalPort())); + + // Make a first request without existing file paths. + HttpTester.Request request = HttpTester.newRequest(); + request.setURI(CONTEXT_PATH + "/last"); + channel.write(request.generate()); + HttpTester.Response response = HttpTester.parseResponse(channel); + assertNotNull(response); + assertEquals(HttpStatus.NO_CONTENT_204, response.getStatus()); + + // Create the specific static file that is requested. + String path = "idx.txt"; + Files.writeString(rootPath.resolve(path), "hello", StandardOpenOption.CREATE); + // Make a second request with the specific file. + request = HttpTester.newRequest(); + request.setURI(CONTEXT_PATH + "/" + path); + channel.write(request.generate()); + response = HttpTester.parseResponse(channel); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + assertEquals("hello", response.getContent()); + + // Create the "maintenance" file, it should be served first. + path = "maintenance.txt"; + Files.writeString(rootPath.resolve(path), "maintenance", StandardOpenOption.CREATE); + // Make a third request with any path, we should get the maintenance file. + request = HttpTester.newRequest(); + request.setURI(CONTEXT_PATH + "/whatever"); + channel.write(request.generate()); + response = HttpTester.parseResponse(channel); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + assertEquals("maintenance", response.getContent()); + } + } + + @Test + public void testTryPathsWithPathMappings() throws Exception + { + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setDirAllowed(false); + + PathMappingsHandler pathMappingsHandler = new PathMappingsHandler(); + pathMappingsHandler.addMapping(new ServletPathSpec("/"), resourceHandler); + pathMappingsHandler.addMapping(new ServletPathSpec("*.php"), new Handler.Abstract() + { + @Override + public Request.Processor handle(Request request) + { + return new Processor() + { + @Override + public void process(Request request, Response response, Callback callback) + { + response.setStatus(HttpStatus.OK_200); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8"); + String message = "PHP: pathInContext=%s, query=%s".formatted(Request.getPathInContext(request), request.getHttpURI().getQuery()); + Content.Sink.write(response, true, message, callback); + } + }; + } + }); + pathMappingsHandler.addMapping(new ServletPathSpec("/forward"), new Handler.Abstract() + { + @Override + public Request.Processor handle(Request request) + { + return new Handler.Processor() + { + public void process(Request request, Response response, Callback callback) + { + assertThat(Request.getPathInContext(request), equalTo("/forward")); + assertThat(request.getHttpURI().getQuery(), equalTo("p=/last")); + response.setStatus(HttpStatus.NO_CONTENT_204); + callback.succeeded(); + } + }; } }); + start(List.of("/maintenance.txt", "$path", "/forward?p=$path"), pathMappingsHandler); + try (SocketChannel channel = SocketChannel.open()) { channel.connect(new InetSocketAddress("localhost", connector.getLocalPort())); // Make a first request without existing file paths. HttpTester.Request request = HttpTester.newRequest(); - request.setURI(contextPath + "/last"); + request.setURI(CONTEXT_PATH + "/last"); channel.write(request.generate()); HttpTester.Response response = HttpTester.parseResponse(channel); assertNotNull(response); assertEquals(HttpStatus.NO_CONTENT_204, response.getStatus()); - // Create the specific file that is requested. + // Create the specific static file that is requested. String path = "idx.txt"; Files.writeString(rootPath.resolve(path), "hello", StandardOpenOption.CREATE); // Make a second request with the specific file. request = HttpTester.newRequest(); - request.setURI(contextPath + "/" + path); + request.setURI(CONTEXT_PATH + "/" + path); channel.write(request.generate()); response = HttpTester.parseResponse(channel); assertNotNull(response); assertEquals(HttpStatus.OK_200, response.getStatus()); assertEquals("hello", response.getContent()); + // Request an existing PHP file. + Files.writeString(rootPath.resolve("index.php"), "raw-php-contents", StandardOpenOption.CREATE); + request = HttpTester.newRequest(); + request.setURI(CONTEXT_PATH + "/index.php"); + channel.write(request.generate()); + response = HttpTester.parseResponse(channel); + assertNotNull(response); + assertEquals(HttpStatus.OK_200, response.getStatus()); + assertThat(response.getContent(), startsWith("PHP: pathInContext=/index.php")); + // Create the "maintenance" file, it should be served first. path = "maintenance.txt"; Files.writeString(rootPath.resolve(path), "maintenance", StandardOpenOption.CREATE); // Make a second request with any path, we should get the maintenance file. request = HttpTester.newRequest(); - request.setURI(contextPath + "/whatever"); + request.setURI(CONTEXT_PATH + "/whatever"); channel.write(request.generate()); response = HttpTester.parseResponse(channel); assertNotNull(response); @@ -165,7 +273,7 @@ public void process(Request request, Response response, Callback callback) sslSocket.connect(new InetSocketAddress("localhost", sslConnector.getLocalPort())); HttpTester.Request request = HttpTester.newRequest(); - request.setURI(contextPath + path); + request.setURI(CONTEXT_PATH + path); OutputStream output = sslSocket.getOutputStream(); output.write(BufferUtil.toArray(request.generate())); output.flush();