Skip to content

Commit

Permalink
Re-Add Static File support (#73)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SentryMan authored Nov 25, 2024
1 parent f9339e8 commit e14efa5
Show file tree
Hide file tree
Showing 33 changed files with 939 additions and 28 deletions.
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,3 @@ var app = Jex.create()
.port(8080)
.start();
```

### TODO
- static file configuration
69 changes: 69 additions & 0 deletions avaje-jex/src/main/java/io/avaje/jex/AbstractStaticHandler.java
Original file line number Diff line number Diff line change
@@ -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<String, String> mimeTypes;
protected final String filesystemRoot;
protected final String urlPrefix;
protected final Predicate<Context> skipFilePredicate;
protected final Map<String, String> headers;
private static final FileNameMap MIME_MAP = URLConnection.getFileNameMap();

protected AbstractStaticHandler(
String urlPrefix,
String filesystemRoot,
Map<String, String> mimeTypes,
Map<String, String> headers,
Predicate<Context> 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");
});
}
}
25 changes: 25 additions & 0 deletions avaje-jex/src/main/java/io/avaje/jex/ClassResourceLoader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.avaje.jex;

import java.io.InputStream;
import java.net.URL;

/**
* Loading resources from the classpath or module path.
*
* <p>When not specified Avaje Jex provides a default implementation that looks to find resources
* using the class loader associated with the ClassResourceLoader.
*
* <p>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);
}
6 changes: 6 additions & 0 deletions avaje-jex/src/main/java/io/avaje/jex/Context.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> headers) {
headers.forEach(this::header);
return this;
}

/**
* Return the response header.
*/
Expand Down
1 change: 1 addition & 0 deletions avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions avaje-jex/src/main/java/io/avaje/jex/DefaultResourceLoader.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 3 additions & 1 deletion avaje-jex/src/main/java/io/avaje/jex/ExchangeHandler.java
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
}
80 changes: 80 additions & 0 deletions avaje-jex/src/main/java/io/avaje/jex/JarResourceHandler.java
Original file line number Diff line number Diff line change
@@ -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<String, String> mimeTypes,
Map<String, String> headers,
Predicate<Context> 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());
}
}
}
20 changes: 17 additions & 3 deletions avaje-jex/src/main/java/io/avaje/jex/Jex.java
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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<StaticContentConfig> consumer) {
var builder = StaticResourceHandlerBuilder.builder();
consumer.accept(builder);

return staticResource(builder);
}

/**
* Set the JsonService.
*/
Expand Down
84 changes: 84 additions & 0 deletions avaje-jex/src/main/java/io/avaje/jex/PathResourceHandler.java
Original file line number Diff line number Diff line change
@@ -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<String, String> mimeTypes,
Map<String, String> headers,
Predicate<Context> 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());
}
}
}
6 changes: 6 additions & 0 deletions avaje-jex/src/main/java/io/avaje/jex/ResourceLocation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.avaje.jex;

public enum ResourceLocation {
CLASS_PATH,
FILE
}
Loading

0 comments on commit e14efa5

Please sign in to comment.