From e14efa5acf31e8501b0c53158279646b2e4853ed Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:07:39 -0500 Subject: [PATCH] Re-Add Static File support (#73) * Merge pull request #66 from SentryMan/internals Refactor Error Handling registration * static resources * use interface * rename * work with jlink * doc * add another assert * Update StaticResourceHandlerBuilder.java * Update README.md * get built in mime types * get exact size * simplify builder * Update JdkServerStart.java * Update StaticResourceHandlerBuilder.java * handle jar uris * Update StaticResourceHandlerBuilder.java * fix jars --- README.md | 3 - .../io/avaje/jex/AbstractStaticHandler.java | 69 ++++++ .../io/avaje/jex/ClassResourceLoader.java | 25 ++ .../src/main/java/io/avaje/jex/Context.java | 6 + .../java/io/avaje/jex/DefaultLifecycle.java | 1 + .../io/avaje/jex/DefaultResourceLoader.java | 47 ++++ .../java/io/avaje/jex/ExchangeHandler.java | 4 +- .../java/io/avaje/jex/JarResourceHandler.java | 80 ++++++ avaje-jex/src/main/java/io/avaje/jex/Jex.java | 20 +- .../io/avaje/jex/PathResourceHandler.java | 84 +++++++ .../java/io/avaje/jex/ResourceLocation.java | 6 + .../io/avaje/jex/StaticContentConfig.java | 93 +++++++ .../java/io/avaje/jex/StaticFileHandler.java | 86 +++++++ .../jex/StaticResourceHandlerBuilder.java | 228 ++++++++++++++++++ .../io/avaje/jex/core/CoreServiceLoader.java | 4 +- .../io/avaje/jex/core/CoreServiceManager.java | 2 +- .../io/avaje/jex/core/ExceptionManager.java | 3 +- .../avaje/jex/http/BadRequestException.java | 9 + .../avaje/jex/http/HttpResponseException.java | 8 - .../http/InternalServerErrorException.java | 9 + .../io/avaje/jex/http/NotFoundException.java | 9 + .../io/avaje/jex/http/RedirectException.java | 9 + .../java/io/avaje/jex/jdk/BaseHandler.java | 4 +- .../java/io/avaje/jex/jdk/JdkContext.java | 5 +- .../java/io/avaje/jex/jdk/JdkServerStart.java | 5 +- .../java/io/avaje/jex/jdk/RoutingFilter.java | 3 +- .../java/io/avaje/jex/routes/RouteEntry.java | 3 +- .../java/io/avaje/jex/routes/SpiRoutes.java | 3 +- .../java/io/avaje/jex/StaticFileTest.java | 126 ++++++++++ .../avaje/jex/jdk/ExceptionManagerTest.java | 2 +- .../java/io/avaje/jex/jdk/FilterTest.java | 1 + .../src/test/resources/public/index.html | 9 + avaje-jex/src/test/resources/public/sus.txt | 1 + 33 files changed, 939 insertions(+), 28 deletions(-) create mode 100644 avaje-jex/src/main/java/io/avaje/jex/AbstractStaticHandler.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/ClassResourceLoader.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/DefaultResourceLoader.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/JarResourceHandler.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/PathResourceHandler.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/ResourceLocation.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/StaticContentConfig.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/StaticFileHandler.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/StaticResourceHandlerBuilder.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/http/BadRequestException.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorException.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/http/NotFoundException.java create mode 100644 avaje-jex/src/main/java/io/avaje/jex/http/RedirectException.java create mode 100644 avaje-jex/src/test/java/io/avaje/jex/StaticFileTest.java create mode 100644 avaje-jex/src/test/resources/public/index.html create mode 100644 avaje-jex/src/test/resources/public/sus.txt diff --git a/README.md b/README.md index 8bcc09c1..05433ead 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,3 @@ var app = Jex.create() .port(8080) .start(); ``` - -### TODO -- static file configuration diff --git a/avaje-jex/src/main/java/io/avaje/jex/AbstractStaticHandler.java b/avaje-jex/src/main/java/io/avaje/jex/AbstractStaticHandler.java new file mode 100644 index 00000000..3e8c8861 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/AbstractStaticHandler.java @@ -0,0 +1,69 @@ +package io.avaje.jex; + +import java.net.FileNameMap; +import java.net.URLConnection; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +import com.sun.net.httpserver.HttpExchange; + +import io.avaje.jex.http.BadRequestException; +import io.avaje.jex.http.NotFoundException; + +abstract sealed class AbstractStaticHandler implements ExchangeHandler + permits StaticFileHandler, PathResourceHandler, JarResourceHandler { + + protected final Map mimeTypes; + protected final String filesystemRoot; + protected final String urlPrefix; + protected final Predicate skipFilePredicate; + protected final Map headers; + private static final FileNameMap MIME_MAP = URLConnection.getFileNameMap(); + + protected AbstractStaticHandler( + String urlPrefix, + String filesystemRoot, + Map mimeTypes, + Map headers, + Predicate skipFilePredicate) { + this.filesystemRoot = filesystemRoot; + this.urlPrefix = urlPrefix; + this.skipFilePredicate = skipFilePredicate; + this.headers = headers; + this.mimeTypes = mimeTypes; + } + + protected void throw404(HttpExchange jdkExchange) { + throw new NotFoundException("File Not Found for request: " + jdkExchange.getRequestURI()); + } + + // This is one function to avoid giving away where we failed + protected void reportPathTraversal() { + throw new BadRequestException("Path traversal attempt detected"); + } + + protected String getExt(String path) { + int slashIndex = path.lastIndexOf('/'); + String basename = (slashIndex < 0) ? path : path.substring(slashIndex + 1); + + int dotIndex = basename.lastIndexOf('.'); + if (dotIndex >= 0) { + return basename.substring(dotIndex + 1); + } else { + return ""; + } + } + + protected String lookupMime(String path) { + var lower = path.toLowerCase(); + + return Objects.requireNonNullElseGet( + MIME_MAP.getContentTypeFor(path), + () -> { + String ext = getExt(lower); + + return mimeTypes.getOrDefault(ext, "application/octet-stream"); + }); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/ClassResourceLoader.java b/avaje-jex/src/main/java/io/avaje/jex/ClassResourceLoader.java new file mode 100644 index 00000000..904866a9 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/ClassResourceLoader.java @@ -0,0 +1,25 @@ +package io.avaje.jex; + +import java.io.InputStream; +import java.net.URL; + +/** + * Loading resources from the classpath or module path. + * + *

When not specified Avaje Jex provides a default implementation that looks to find resources + * using the class loader associated with the ClassResourceLoader. + * + *

As a fallback, {@link ClassLoader#getSystemResourceAsStream(String)} is used if the loader returns null. + */ +public interface ClassResourceLoader { + + static ClassResourceLoader fromClass(Class clazz) { + + return new DefaultResourceLoader(clazz); + } + + /** Return the URL for the given resource or return null if it cannot be found. */ + URL getResource(String resourcePath); + + InputStream getResourceAsStream(String resourcePath); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/Context.java b/avaje-jex/src/main/java/io/avaje/jex/Context.java index 1d4fc4d2..a774c141 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/Context.java +++ b/avaje-jex/src/main/java/io/avaje/jex/Context.java @@ -323,6 +323,12 @@ default Context render(String name) { */ Context header(String key, String value); + /** Set the response headers using the provided map. */ + default Context headers(Map headers) { + headers.forEach(this::header); + return this; + } + /** * Return the response header. */ diff --git a/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java b/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java index f19622a2..02746e7c 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java +++ b/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java @@ -3,6 +3,7 @@ import io.avaje.applog.AppLog; import java.lang.System.Logger.Level; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; diff --git a/avaje-jex/src/main/java/io/avaje/jex/DefaultResourceLoader.java b/avaje-jex/src/main/java/io/avaje/jex/DefaultResourceLoader.java new file mode 100644 index 00000000..0a82317a --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/DefaultResourceLoader.java @@ -0,0 +1,47 @@ +package io.avaje.jex; + +import java.io.InputStream; +import java.net.URL; +import java.util.Objects; +import java.util.Optional; + +final class DefaultResourceLoader implements ClassResourceLoader { + + private final Class clazz; + + DefaultResourceLoader(Class clazz) { + + this.clazz = clazz; + } + + @Override + public URL getResource(String resourcePath) { + + var url = clazz.getResource(resourcePath); + if (url == null) { + // search the module path for top level resource + url = + Optional.ofNullable(ClassLoader.getSystemResource(resourcePath)) + .orElseGet( + () -> Thread.currentThread().getContextClassLoader().getResource(resourcePath)); + } + return Objects.requireNonNull(url, "Unable to locate resource: " + resourcePath); + } + + @Override + public InputStream getResourceAsStream(String resourcePath) { + + var url = clazz.getResourceAsStream(resourcePath); + if (url == null) { + // search the module path for top level resource + url = + Optional.ofNullable(ClassLoader.getSystemResourceAsStream(resourcePath)) + .orElseGet( + () -> + Thread.currentThread() + .getContextClassLoader() + .getResourceAsStream(resourcePath)); + } + return Objects.requireNonNull(url, "Unable to locate resource: " + resourcePath); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/ExchangeHandler.java b/avaje-jex/src/main/java/io/avaje/jex/ExchangeHandler.java index 0a6a7d5c..f3680a70 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/ExchangeHandler.java +++ b/avaje-jex/src/main/java/io/avaje/jex/ExchangeHandler.java @@ -1,5 +1,7 @@ package io.avaje.jex; +import java.io.IOException; + /** * A handler which is invoked to process HTTP exchanges. Each HTTP exchange is handled by one of * these handlers. @@ -14,5 +16,5 @@ public interface ExchangeHandler { * @param ctx the request context containing the request from the client and used to send the * response */ - void handle(Context ctx); + void handle(Context ctx) throws IOException; } diff --git a/avaje-jex/src/main/java/io/avaje/jex/JarResourceHandler.java b/avaje-jex/src/main/java/io/avaje/jex/JarResourceHandler.java new file mode 100644 index 00000000..4d91db4d --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/JarResourceHandler.java @@ -0,0 +1,80 @@ +package io.avaje.jex; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Paths; +import java.util.Map; +import java.util.function.Predicate; + +final class JarResourceHandler extends AbstractStaticHandler implements ExchangeHandler { + + private final URL indexFile; + private final URL singleFile; + private final ClassResourceLoader resourceLoader; + + JarResourceHandler( + String urlPrefix, + String filesystemRoot, + Map mimeTypes, + Map headers, + Predicate skipFilePredicate, + ClassResourceLoader resourceLoader, + URL indexFile, + URL singleFile) { + super(urlPrefix, filesystemRoot, mimeTypes, headers, skipFilePredicate); + + this.resourceLoader = resourceLoader; + this.indexFile = indexFile; + this.singleFile = singleFile; + } + + @Override + public void handle(Context ctx) throws IOException { + + final var jdkExchange = ctx.jdkExchange(); + + if (singleFile != null) { + sendURL(ctx, singleFile.getPath(), singleFile); + return; + } + + if (skipFilePredicate.test(ctx)) { + throw404(jdkExchange); + } + + final String wholeUrlPath = jdkExchange.getRequestURI().getPath(); + + if (wholeUrlPath.endsWith("/") || wholeUrlPath.equals(urlPrefix)) { + sendURL(ctx, indexFile.getPath(), indexFile); + return; + } + + final String urlPath = wholeUrlPath.substring(urlPrefix.length()); + + final String normalizedPath = + Paths.get(filesystemRoot, urlPath).normalize().toString().replace("\\", "/"); + + if (!normalizedPath.startsWith(filesystemRoot)) { + reportPathTraversal(); + } + + try (var fis = resourceLoader.getResourceAsStream(normalizedPath)) { + ctx.header("Content-Type", lookupMime(normalizedPath)); + ctx.headers(headers); + ctx.write(fis); + } catch (final Exception e) { + throw404(ctx.jdkExchange()); + } + } + + private void sendURL(Context ctx, String urlPath, URL path) throws IOException { + + try (var fis = path.openStream()) { + ctx.header("Content-Type", lookupMime(urlPath)); + ctx.headers(headers); + ctx.write(fis); + } catch (final Exception e) { + throw404(ctx.jdkExchange()); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/Jex.java b/avaje-jex/src/main/java/io/avaje/jex/Jex.java index 459ccf91..8610fd57 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/Jex.java +++ b/avaje-jex/src/main/java/io/avaje/jex/Jex.java @@ -1,12 +1,12 @@ package io.avaje.jex; +import java.util.Collection; +import java.util.function.Consumer; + import io.avaje.inject.BeanScope; import io.avaje.jex.spi.JsonService; import io.avaje.jex.spi.TemplateRender; -import java.util.Collection; -import java.util.function.Consumer; - /** * Create configure and start Jex. * @@ -70,6 +70,20 @@ static Jex create() { */ Routing routing(); + /** Add a static resource route */ + default Jex staticResource(StaticContentConfig config) { + routing().get(config.httpPath(), config.createHandler()); + return this; + } + + /** Add a static resource route using a consumer */ + default Jex staticResource(Consumer consumer) { + var builder = StaticResourceHandlerBuilder.builder(); + consumer.accept(builder); + + return staticResource(builder); + } + /** * Set the JsonService. */ diff --git a/avaje-jex/src/main/java/io/avaje/jex/PathResourceHandler.java b/avaje-jex/src/main/java/io/avaje/jex/PathResourceHandler.java new file mode 100644 index 00000000..ff5ae3da --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/PathResourceHandler.java @@ -0,0 +1,84 @@ +package io.avaje.jex; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Predicate; + +final class PathResourceHandler extends AbstractStaticHandler implements ExchangeHandler { + + private final Path indexFile; + private final Path singleFile; + + PathResourceHandler( + String urlPrefix, + String filesystemRoot, + Map mimeTypes, + Map headers, + Predicate skipFilePredicate, + Path indexFile, + Path singleFile) { + super(urlPrefix, filesystemRoot, mimeTypes, headers, skipFilePredicate); + + this.indexFile = indexFile; + this.singleFile = singleFile; + } + + @Override + public void handle(Context ctx) throws IOException { + + if (singleFile != null) { + sendPathIS(ctx, singleFile.toString(), singleFile); + return; + } + + final var jdkExchange = ctx.jdkExchange(); + if (skipFilePredicate.test(ctx)) { + throw404(jdkExchange); + } + + final String wholeUrlPath = jdkExchange.getRequestURI().getPath(); + + if (wholeUrlPath.endsWith("/") || wholeUrlPath.equals(urlPrefix)) { + sendPathIS(ctx, indexFile.toString(), indexFile); + + return; + } + + final String urlPath = wholeUrlPath.substring(urlPrefix.length()); + + Path path; + try { + path = Path.of(filesystemRoot, urlPath).toRealPath(); + + } catch (final IOException e) { + // This may be more benign (i.e. not an attack, just a 403), + // but we don't want an attacker to be able to discern the difference. + reportPathTraversal(); + return; + } + + final String canonicalPath = path.toString(); + if (!canonicalPath.startsWith(filesystemRoot)) { + reportPathTraversal(); + } + + sendPathIS(ctx, urlPath, path); + } + + private void sendPathIS(Context ctx, String urlPath, Path path) throws IOException { + final var exchange = ctx.jdkExchange(); + final String mimeType = lookupMime(urlPath); + ctx.header("Content-Type", mimeType); + ctx.headers(headers); + exchange.sendResponseHeaders(200, Files.size(path)); + try (var fis = Files.newInputStream(path); + var os = exchange.getResponseBody()) { + + fis.transferTo(os); + } catch (final Exception e) { + throw404(ctx.jdkExchange()); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/ResourceLocation.java b/avaje-jex/src/main/java/io/avaje/jex/ResourceLocation.java new file mode 100644 index 00000000..cd5da741 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/ResourceLocation.java @@ -0,0 +1,6 @@ +package io.avaje.jex; + +public enum ResourceLocation { + CLASS_PATH, + FILE +} \ No newline at end of file diff --git a/avaje-jex/src/main/java/io/avaje/jex/StaticContentConfig.java b/avaje-jex/src/main/java/io/avaje/jex/StaticContentConfig.java new file mode 100644 index 00000000..14aa1e11 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/StaticContentConfig.java @@ -0,0 +1,93 @@ +package io.avaje.jex; + +import java.net.URLConnection; +import java.util.function.Predicate; + +/** Builder for a static resource exchange handler. */ +public sealed interface StaticContentConfig permits StaticResourceHandlerBuilder { + + static StaticContentConfig create() { + return StaticResourceHandlerBuilder.builder(); + } + + /** Return a new ExchangeHandler that will serve the resources */ + ExchangeHandler createHandler(); + + /** + * Sets the HTTP path for the static resource handler. + * + * @param path the HTTP path prefix + * @return the updated configuration + */ + StaticContentConfig httpPath(String path); + + /** + * Gets the current HTTP path. + * + * @return the current HTTP path + */ + String httpPath(); + + /** + * Sets the file to serve, or the folder your files are located in. (default: "/public/") + * + * @param root the root directory + * @return the updated configuration + */ + StaticContentConfig resource(String resource); + + /** + * Sets the index file to be served when a directory is requests. + * + * @param directoryIndex the index file + * @return the updated configuration + */ + StaticContentConfig directoryIndex(String directoryIndex); + + /** + * Sets a custom resource loader for loading class/module path resources. This is normally used + * when running the application on the module path when files cannot be discovered. + * + *

Example usage: {@code config.resourceLoader(ClassResourceLoader.create(getClass())) } + * + * @param resourceLoader the custom resource loader + * @return the updated configuration + */ + StaticContentConfig resourceLoader(ClassResourceLoader resourceLoader); + + /** + * Adds a new MIME type mapping to the configuration. (Default: uses {@link + * URLConnection#getFileNameMap()} + * + * @param ext the file extension (e.g., "html", "css", "js") + * @param mimeType the corresponding MIME type (e.g., "text/html", "text/css", + * "application/javascript") + * @return the updated configuration + */ + StaticContentConfig putMimeTypeMapping(String ext, String mimeType); + + /** + * Adds a new response header to the configuration. + * + * @param key the header name + * @param value the header value + * @return the updated configuration + */ + StaticContentConfig putResponseHeader(String key, String value); + + /** + * Sets a predicate to filter files based on the request context. + * + * @param skipFilePredicate the predicate to use + * @return the updated configuration + */ + StaticContentConfig skipFilePredicate(Predicate skipFilePredicate); + + /** + * Sets the resource location (CLASSPATH or FILE). + * + * @param location the resource location + * @return the updated configuration + */ + StaticContentConfig location(ResourceLocation location); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/StaticFileHandler.java b/avaje-jex/src/main/java/io/avaje/jex/StaticFileHandler.java new file mode 100644 index 00000000..2e728956 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/StaticFileHandler.java @@ -0,0 +1,86 @@ +package io.avaje.jex; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Map; +import java.util.function.Predicate; + +import com.sun.net.httpserver.HttpExchange; + +final class StaticFileHandler extends AbstractStaticHandler implements ExchangeHandler { + + private final File indexFile; + private final File singleFile; + + StaticFileHandler( + String urlPrefix, + String filesystemRoot, + Map mimeTypes, + Map headers, + Predicate skipFilePredicate, + File welcomeFile, + File singleFile) { + super(urlPrefix, filesystemRoot, mimeTypes, headers, skipFilePredicate); + this.indexFile = welcomeFile; + this.singleFile = singleFile; + } + + @Override + public void handle(Context ctx) throws IOException { + + final var jdkExchange = ctx.jdkExchange(); + + if (singleFile != null) { + sendFile(ctx, jdkExchange, singleFile.getPath(), singleFile); + return; + } + + if (skipFilePredicate.test(ctx)) { + throw404(jdkExchange); + } + + final String wholeUrlPath = jdkExchange.getRequestURI().getPath(); + + if (wholeUrlPath.endsWith("/") || wholeUrlPath.equals(urlPrefix)) { + sendFile(ctx, jdkExchange, indexFile.getPath(), indexFile); + + return; + } + + final String urlPath = wholeUrlPath.substring(urlPrefix.length()); + + File canonicalFile; + try { + canonicalFile = new File(filesystemRoot, urlPath).getCanonicalFile(); + + } catch (IOException e) { + // This may be more benign (i.e. not an attack, just a 403), + // but we don't want an attacker to be able to discern the difference. + reportPathTraversal(); + return; + } + + String canonicalPath = canonicalFile.getPath(); + if (!canonicalPath.startsWith(filesystemRoot)) { + reportPathTraversal(); + } + + sendFile(ctx, jdkExchange, urlPath, canonicalFile); + } + + private void sendFile(Context ctx, HttpExchange jdkExchange, String urlPath, File canonicalFile) + throws IOException { + try (var fis = new FileInputStream(canonicalFile)) { + + String mimeType = lookupMime(urlPath); + ctx.header("Content-Type", mimeType); + ctx.headers(headers); + jdkExchange.sendResponseHeaders(200, canonicalFile.length()); + fis.transferTo(jdkExchange.getResponseBody()); + } catch (FileNotFoundException e) { + throw404(jdkExchange); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/StaticResourceHandlerBuilder.java b/avaje-jex/src/main/java/io/avaje/jex/StaticResourceHandlerBuilder.java new file mode 100644 index 00000000..8dc482a5 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/StaticResourceHandlerBuilder.java @@ -0,0 +1,228 @@ +package io.avaje.jex; + +import static io.avaje.jex.ResourceLocation.CLASS_PATH; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; + +final class StaticResourceHandlerBuilder implements StaticContentConfig { + + private static final String FAILED_TO_LOCATE_FILE = "Failed to locate file: "; + private static final String DIRECTORY_INDEX_FAILURE = + "Failed to locate Directory Index Resource: "; + private static final Predicate NO_OP_PREDICATE = ctx -> false; + private static final ClassResourceLoader DEFALT_LOADER = + ClassResourceLoader.fromClass(StaticResourceHandlerBuilder.class); + + private String path = "/"; + private String root = "/public/"; + private String directoryIndex = null; + private ClassResourceLoader resourceLoader = DEFALT_LOADER; + private final Map mimeTypes = new HashMap<>(); + private final Map headers = new HashMap<>(); + private Predicate skipFilePredicate = NO_OP_PREDICATE; + private ResourceLocation location = CLASS_PATH; + + private StaticResourceHandlerBuilder() {} + + public static StaticResourceHandlerBuilder builder() { + return new StaticResourceHandlerBuilder(); + } + + @Override + public ExchangeHandler createHandler() { + + path = + Objects.requireNonNull(path) + .transform(this::prependSlash) + .transform(s -> s.endsWith("/*") ? s.substring(0, s.length() - 2) : s); + + final var isClasspath = location == CLASS_PATH; + + root = isClasspath ? root.transform(this::prependSlash) : root; + if (isClasspath && "/".equals(root)) { + throw new IllegalArgumentException( + "Cannot serve full classpath, please configure a classpath prefix"); + } + + if (root.endsWith("/") && directoryIndex == null) { + throw new IllegalArgumentException( + "Directory Index file is required when serving directories"); + } + + if (location == ResourceLocation.FILE) { + return fileLoader(File::new); + } + + return classPathHandler(); + } + + @Override + public StaticResourceHandlerBuilder httpPath(String path) { + this.path = path; + return this; + } + + @Override + public String httpPath() { + return path; + } + + @Override + public StaticResourceHandlerBuilder resource(String directory) { + this.root = directory; + return this; + } + + @Override + public StaticResourceHandlerBuilder directoryIndex(String directoryIndex) { + this.directoryIndex = directoryIndex; + return this; + } + + @Override + public StaticResourceHandlerBuilder resourceLoader(ClassResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + return this; + } + + @Override + public StaticResourceHandlerBuilder putMimeTypeMapping(String key, String value) { + this.mimeTypes.put(key, value); + return this; + } + + @Override + public StaticResourceHandlerBuilder putResponseHeader(String key, String value) { + this.headers.put(key, value); + return this; + } + + @Override + public StaticResourceHandlerBuilder skipFilePredicate(Predicate skipFilePredicate) { + this.skipFilePredicate = skipFilePredicate; + return this; + } + + @Override + public StaticResourceHandlerBuilder location(ResourceLocation location) { + this.location = location; + return this; + } + + private String prependSlash(String s) { + return s.startsWith("/") ? s : "/" + s; + } + + private String appendSlash(String s) { + return s.endsWith("/") ? s : s + "/"; + } + + private ExchangeHandler fileLoader(Function fileLoader) { + String fsRoot; + File dirIndex = null; + File singleFile = null; + if (directoryIndex != null) { + try { + + dirIndex = + fileLoader.apply(root.transform(this::appendSlash) + directoryIndex).getCanonicalFile(); + + fsRoot = dirIndex.getParentFile().getPath(); + } catch (Exception e) { + throw new IllegalStateException( + DIRECTORY_INDEX_FAILURE + root.transform(this::appendSlash) + directoryIndex, e); + } + } else { + try { + + singleFile = fileLoader.apply(root).getCanonicalFile(); + + fsRoot = singleFile.getParentFile().getPath(); + } catch (Exception e) { + throw new IllegalStateException(FAILED_TO_LOCATE_FILE + root, e); + } + } + + return new StaticFileHandler( + path, fsRoot, mimeTypes, headers, skipFilePredicate, dirIndex, singleFile); + } + + private ExchangeHandler classPathHandler() { + Function urlFunc = resourceLoader::getResource; + + Function loaderFunc = urlFunc.andThen(this::toURI); + String fsRoot; + Path dirIndex = null; + Path singleFile = null; + if (directoryIndex != null) { + try { + var uri = loaderFunc.apply(root.transform(this::appendSlash) + directoryIndex); + + if ("jar".equals(uri.getScheme())) { + + var url = uri.toURL(); + return jarLoader(url.toString().transform(this::getJARRoot), url, null); + } + dirIndex = Paths.get(uri).toRealPath(); + fsRoot = Paths.get(uri).getParent().toString(); + + } catch (Exception e) { + + throw new IllegalStateException( + DIRECTORY_INDEX_FAILURE + root.transform(this::appendSlash) + directoryIndex, e); + } + } else { + try { + var uri = loaderFunc.apply(root); + + if ("jar".equals(uri.getScheme())) { + + var url = uri.toURL(); + + return jarLoader(url.toString().transform(this::getJARRoot), null, uri.toURL()); + } + + singleFile = Paths.get(uri).toRealPath(); + + fsRoot = singleFile.getParent().toString(); + + } catch (Exception e) { + + throw new IllegalStateException(FAILED_TO_LOCATE_FILE + root, e); + } + } + + return new PathResourceHandler( + path, fsRoot, mimeTypes, headers, skipFilePredicate, dirIndex, singleFile); + } + + private String getJARRoot(String s) { + return s.substring(0, s.lastIndexOf("/")).substring(s.indexOf("jar!") + 4); + } + + private ExchangeHandler jarLoader(String fsRoot, URL dirIndex, URL singleFile) { + + return new JarResourceHandler( + path, fsRoot, mimeTypes, headers, skipFilePredicate, resourceLoader, dirIndex, singleFile); + } + + private URI toURI(URL url) { + + try { + + return url.toURI(); + } catch (URISyntaxException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceLoader.java b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceLoader.java index 56fe2962..99ac7348 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceLoader.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceLoader.java @@ -10,7 +10,7 @@ import io.avaje.jex.spi.TemplateRender; /** Core implementation of SpiServiceManager provided to specific implementations like jetty etc. */ -final class CoreServiceLoader { +public final class CoreServiceLoader { private static final CoreServiceLoader INSTANCE = new CoreServiceLoader(); @@ -30,7 +30,7 @@ final class CoreServiceLoader { jsonService = spiJsonService; } - public static Optional getJsonService() { + public static Optional jsonService() { return Optional.ofNullable(INSTANCE.jsonService); } diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java index c2376d33..74a3082b 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java @@ -159,7 +159,7 @@ JsonService initJsonService() { if (jsonService != null) { return jsonService; } - return CoreServiceLoader.getJsonService() + return CoreServiceLoader.jsonService() .orElseGet(this::defaultJsonService); } diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java index 481944ce..ddafb842 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java @@ -8,6 +8,7 @@ import io.avaje.jex.ExceptionHandler; import io.avaje.jex.http.ErrorCode; import io.avaje.jex.http.HttpResponseException; +import io.avaje.jex.http.InternalServerErrorException; import io.avaje.jex.spi.HeaderKeys; import io.avaje.jex.spi.SpiContext; @@ -49,7 +50,7 @@ void handle(SpiContext ctx, Exception e) { private void unhandledException(SpiContext ctx, Exception e) { log.log(WARNING, "Uncaught exception", e); - defaultHandling(ctx, new HttpResponseException(ErrorCode.INTERNAL_SERVER_ERROR)); + defaultHandling(ctx, new InternalServerErrorException(ErrorCode.INTERNAL_SERVER_ERROR.message())); } private void defaultHandling(SpiContext ctx, HttpResponseException exception) { diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestException.java b/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestException.java new file mode 100644 index 00000000..c9cc523a --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestException.java @@ -0,0 +1,9 @@ +package io.avaje.jex.http; + +/** Thrown when unable to find a route/resource */ +public class BadRequestException extends HttpResponseException { + + public BadRequestException(String message) { + super(400, message); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java b/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java index 7258f667..613894f2 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java +++ b/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java @@ -22,14 +22,6 @@ public HttpResponseException(int status, String message) { this(status, message, Collections.emptyMap()); } - public HttpResponseException(ErrorCode code) { - this(code.status(), code.message()); - } - - public HttpResponseException(ErrorCode code, Map details) { - this(code.status(), code.message(), details); - } - public int getStatus() { return status; } diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorException.java b/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorException.java new file mode 100644 index 00000000..4ddc7716 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorException.java @@ -0,0 +1,9 @@ +package io.avaje.jex.http; + +/** Thrown when unable to find a route/resource */ +public class InternalServerErrorException extends HttpResponseException { + + public InternalServerErrorException(String message) { + super(500, message); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundException.java b/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundException.java new file mode 100644 index 00000000..5a0caa5a --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundException.java @@ -0,0 +1,9 @@ +package io.avaje.jex.http; + +/** Thrown when unable to find a route/resource */ +public class NotFoundException extends HttpResponseException { + + public NotFoundException(String message) { + super(404, message); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/RedirectException.java b/avaje-jex/src/main/java/io/avaje/jex/http/RedirectException.java new file mode 100644 index 00000000..6bdaaefb --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/RedirectException.java @@ -0,0 +1,9 @@ +package io.avaje.jex.http; + +/** Thrown when redirecting */ +public class RedirectException extends HttpResponseException { + + public RedirectException(String message) { + super(302, message); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/jdk/BaseHandler.java b/avaje-jex/src/main/java/io/avaje/jex/jdk/BaseHandler.java index 4ae5d14c..ca744b4a 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/jdk/BaseHandler.java +++ b/avaje-jex/src/main/java/io/avaje/jex/jdk/BaseHandler.java @@ -1,5 +1,7 @@ package io.avaje.jex.jdk; +import java.io.IOException; + import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; @@ -20,7 +22,7 @@ void waitForIdle(long maxSeconds) { } @Override - public void handle(HttpExchange exchange) { + public void handle(HttpExchange exchange) throws IOException { JdkContext ctx = (JdkContext) exchange.getAttribute("JdkContext"); ExchangeHandler handlerConsumer = diff --git a/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java b/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java index 8a99a481..6eb2473e 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java +++ b/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkContext.java @@ -27,6 +27,7 @@ import io.avaje.jex.Routing; import io.avaje.jex.http.ErrorCode; import io.avaje.jex.http.HttpResponseException; +import io.avaje.jex.http.RedirectException; import io.avaje.jex.security.BasicAuthCredentials; import io.avaje.jex.security.Role; import io.avaje.jex.spi.HeaderKeys; @@ -150,7 +151,7 @@ public void redirect(String location, int statusCode) { header(HeaderKeys.LOCATION, location); status(statusCode); if (mode == Routing.Type.FILTER) { - throw new HttpResponseException(ErrorCode.REDIRECT); + throw new RedirectException(ErrorCode.REDIRECT.message()); } else { performRedirect(); } @@ -351,7 +352,7 @@ public Context write(String content) { public Context write(byte[] bytes) { try (var os = exchange.getResponseBody()) { - exchange.sendResponseHeaders(statusCode(), bytes.length); + exchange.sendResponseHeaders(statusCode(), bytes.length == 0 ? -1 : bytes.length); os.write(bytes); os.flush(); diff --git a/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkServerStart.java b/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkServerStart.java index 157c4890..9b0e11f0 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkServerStart.java +++ b/avaje-jex/src/main/java/io/avaje/jex/jdk/JdkServerStart.java @@ -49,8 +49,9 @@ public Jex.Server start(Jex jex, SpiRoutes routes, SpiServiceManager serviceMana server.start(); jex.lifecycle().status(AppLifecycle.Status.STARTED); - String jexVersion = Jex.class.getPackage().getImplementationVersion(); - log.log(Level.INFO, "started server on port {0,number,#} version {1}", port, jexVersion); + log.log( + Level.INFO, + "started com.sun.net.httpserver.HttpServer on port %s://%s".formatted(scheme, port)); return new JdkJexServer(server, jex.lifecycle(), handler); } catch (IOException e) { throw new UncheckedIOException(e); diff --git a/avaje-jex/src/main/java/io/avaje/jex/jdk/RoutingFilter.java b/avaje-jex/src/main/java/io/avaje/jex/jdk/RoutingFilter.java index a092f0cf..04dd04a6 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/jdk/RoutingFilter.java +++ b/avaje-jex/src/main/java/io/avaje/jex/jdk/RoutingFilter.java @@ -10,6 +10,7 @@ import io.avaje.jex.Routing; import io.avaje.jex.Routing.Type; import io.avaje.jex.http.HttpResponseException; +import io.avaje.jex.http.NotFoundException; import io.avaje.jex.routes.SpiRoutes; import io.avaje.jex.spi.SpiContext; @@ -77,7 +78,7 @@ private void processNoRoute(JdkContext ctx, String uri, Routing.Type routeType) ctx.status(200); return; } - throw new HttpResponseException(404, "uri: " + uri); + throw new NotFoundException("uri: " + uri); } private boolean hasGetHandler(String uri) { diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java index 48177a4f..25eae142 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java @@ -1,5 +1,6 @@ package io.avaje.jex.routes; +import java.io.IOException; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; @@ -42,7 +43,7 @@ public boolean matches(String requestUri) { } @Override - public void handle(Context ctx) { + public void handle(Context ctx) throws IOException { handler.handle(ctx); } diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java b/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java index be4f2bfc..54068511 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java @@ -1,5 +1,6 @@ package io.avaje.jex.routes; +import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Set; @@ -57,7 +58,7 @@ interface Entry { /** * Handle the request. */ - void handle(Context ctx); + void handle(Context ctx) throws IOException; /** * Return the path parameter map given the uri. diff --git a/avaje-jex/src/test/java/io/avaje/jex/StaticFileTest.java b/avaje-jex/src/test/java/io/avaje/jex/StaticFileTest.java new file mode 100644 index 00000000..e3cf73ce --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/StaticFileTest.java @@ -0,0 +1,126 @@ +package io.avaje.jex; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.jdk.TestPair; + +class StaticFileTest { + + static TestPair pair = init(); + + static TestPair init() { + + final Jex app = + Jex.create() + .staticResource(b -> defaultCP(b.httpPath("/index"))) + .staticResource(b -> defaultFile(b.httpPath("/indexFile"))) + .staticResource(b -> defaultCP(b.httpPath("/indexWild/*"))) + .staticResource(b -> defaultFile(b.httpPath("/indexWildFile/*"))) + .staticResource(b -> defaultCP(b.httpPath("/sus/*"))) + .staticResource(b -> defaultFile(b.httpPath("/susFile/*"))) + .staticResource(b -> b.httpPath("/single").resource("/logback.xml")) + .staticResource( + b -> + b.location(ResourceLocation.FILE) + .httpPath("/singleFile") + .resource("src/test/resources/logback.xml")); + + return TestPair.create(app); + } + + private static StaticContentConfig defaultFile(StaticContentConfig b) { + return b.location(ResourceLocation.FILE) + .resource("src/test/resources/public") + .directoryIndex("index.html"); + } + + private static StaticContentConfig defaultCP(StaticContentConfig b) { + return b.resource("/public").directoryIndex("index.html"); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void testGet() { + HttpResponse res = pair.request().path("index").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + } + + @Test + void testTraversal() { + HttpResponse res = pair.request().path("indexWild/../hmm").GET().asString(); + assertThat(res.statusCode()).isEqualTo(400); + } + + @Test + void getIndexWildCP() { + HttpResponse res = pair.request().path("indexWild/").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void getIndex404() { + HttpResponse res = pair.request().path("index").path("index.html").GET().asString(); + assertThat(res.statusCode()).isEqualTo(404); + } + + @Test + void getDirContentCP() { + HttpResponse res = + pair.request().requestTimeout(Duration.ofHours(1)).path("sus/sus.txt").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).contains("ඞ"); + } + + @Test + void getSingleFileCP() { + HttpResponse res = pair.request().path("single").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("xml"); + } + + @Test + void getIndexFile() { + HttpResponse res = pair.request().path("indexFile").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void getDirContentFile() { + HttpResponse res = + pair.request().requestTimeout(Duration.ofHours(1)).path("susFile/sus.txt").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).contains("ඞ"); + } + + @Test + void getSingleResourceFile() { + HttpResponse res = pair.request().path("singleFile").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("xml"); + } + + @Test + void getIndexWildFile() { + HttpResponse res = pair.request().path("indexWildFile/").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void testFileTraversal() { + HttpResponse res = pair.request().path("indexWildFile/../traverse").GET().asString(); + assertThat(res.statusCode()).isEqualTo(400); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java b/avaje-jex/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java index dcc3adea..446526c1 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java @@ -19,7 +19,7 @@ static TestPair init() { final Jex app = Jex.create() .routing(routing -> routing .get("/", ctx -> { - throw new HttpResponseException(ErrorCode.FORBIDDEN); + throw new HttpResponseException(ErrorCode.FORBIDDEN.status(), ErrorCode.FORBIDDEN.message()); }) .post("/", ctx -> { throw new IllegalStateException("foo"); diff --git a/avaje-jex/src/test/java/io/avaje/jex/jdk/FilterTest.java b/avaje-jex/src/test/java/io/avaje/jex/jdk/FilterTest.java index a40afe17..bb1d1fcf 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/jdk/FilterTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/jdk/FilterTest.java @@ -32,6 +32,7 @@ static TestPair init() { if (ctx.url().contains("/two/")) { ctx.header("before-two", "set"); } + ctx.jdkExchange().getRequestURI().getPath(); chain.proceed(); }) .after(ctx -> afterAll.set("set")) diff --git a/avaje-jex/src/test/resources/public/index.html b/avaje-jex/src/test/resources/public/index.html new file mode 100644 index 00000000..41abec16 --- /dev/null +++ b/avaje-jex/src/test/resources/public/index.html @@ -0,0 +1,9 @@ + + + + Index.html + + +

This ia my first page.

+ + \ No newline at end of file diff --git a/avaje-jex/src/test/resources/public/sus.txt b/avaje-jex/src/test/resources/public/sus.txt new file mode 100644 index 00000000..b20ae04e --- /dev/null +++ b/avaje-jex/src/test/resources/public/sus.txt @@ -0,0 +1 @@ +ඞ \ No newline at end of file