Skip to content

Commit e14efa5

Browse files
authored
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
1 parent f9339e8 commit e14efa5

33 files changed

+939
-28
lines changed

README.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,3 @@ var app = Jex.create()
2323
.port(8080)
2424
.start();
2525
```
26-
27-
### TODO
28-
- static file configuration
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package io.avaje.jex;
2+
3+
import java.net.FileNameMap;
4+
import java.net.URLConnection;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
import java.util.function.Predicate;
8+
9+
import com.sun.net.httpserver.HttpExchange;
10+
11+
import io.avaje.jex.http.BadRequestException;
12+
import io.avaje.jex.http.NotFoundException;
13+
14+
abstract sealed class AbstractStaticHandler implements ExchangeHandler
15+
permits StaticFileHandler, PathResourceHandler, JarResourceHandler {
16+
17+
protected final Map<String, String> mimeTypes;
18+
protected final String filesystemRoot;
19+
protected final String urlPrefix;
20+
protected final Predicate<Context> skipFilePredicate;
21+
protected final Map<String, String> headers;
22+
private static final FileNameMap MIME_MAP = URLConnection.getFileNameMap();
23+
24+
protected AbstractStaticHandler(
25+
String urlPrefix,
26+
String filesystemRoot,
27+
Map<String, String> mimeTypes,
28+
Map<String, String> headers,
29+
Predicate<Context> skipFilePredicate) {
30+
this.filesystemRoot = filesystemRoot;
31+
this.urlPrefix = urlPrefix;
32+
this.skipFilePredicate = skipFilePredicate;
33+
this.headers = headers;
34+
this.mimeTypes = mimeTypes;
35+
}
36+
37+
protected void throw404(HttpExchange jdkExchange) {
38+
throw new NotFoundException("File Not Found for request: " + jdkExchange.getRequestURI());
39+
}
40+
41+
// This is one function to avoid giving away where we failed
42+
protected void reportPathTraversal() {
43+
throw new BadRequestException("Path traversal attempt detected");
44+
}
45+
46+
protected String getExt(String path) {
47+
int slashIndex = path.lastIndexOf('/');
48+
String basename = (slashIndex < 0) ? path : path.substring(slashIndex + 1);
49+
50+
int dotIndex = basename.lastIndexOf('.');
51+
if (dotIndex >= 0) {
52+
return basename.substring(dotIndex + 1);
53+
} else {
54+
return "";
55+
}
56+
}
57+
58+
protected String lookupMime(String path) {
59+
var lower = path.toLowerCase();
60+
61+
return Objects.requireNonNullElseGet(
62+
MIME_MAP.getContentTypeFor(path),
63+
() -> {
64+
String ext = getExt(lower);
65+
66+
return mimeTypes.getOrDefault(ext, "application/octet-stream");
67+
});
68+
}
69+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.avaje.jex;
2+
3+
import java.io.InputStream;
4+
import java.net.URL;
5+
6+
/**
7+
* Loading resources from the classpath or module path.
8+
*
9+
* <p>When not specified Avaje Jex provides a default implementation that looks to find resources
10+
* using the class loader associated with the ClassResourceLoader.
11+
*
12+
* <p>As a fallback, {@link ClassLoader#getSystemResourceAsStream(String)} is used if the loader returns null.
13+
*/
14+
public interface ClassResourceLoader {
15+
16+
static ClassResourceLoader fromClass(Class<?> clazz) {
17+
18+
return new DefaultResourceLoader(clazz);
19+
}
20+
21+
/** Return the URL for the given resource or return null if it cannot be found. */
22+
URL getResource(String resourcePath);
23+
24+
InputStream getResourceAsStream(String resourcePath);
25+
}

avaje-jex/src/main/java/io/avaje/jex/Context.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ default Context render(String name) {
323323
*/
324324
Context header(String key, String value);
325325

326+
/** Set the response headers using the provided map. */
327+
default Context headers(Map<String, String> headers) {
328+
headers.forEach(this::header);
329+
return this;
330+
}
331+
326332
/**
327333
* Return the response header.
328334
*/

avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.avaje.applog.AppLog;
44

55
import java.lang.System.Logger.Level;
6+
import java.nio.file.Path;
67
import java.util.ArrayList;
78
import java.util.Collections;
89
import java.util.List;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package io.avaje.jex;
2+
3+
import java.io.InputStream;
4+
import java.net.URL;
5+
import java.util.Objects;
6+
import java.util.Optional;
7+
8+
final class DefaultResourceLoader implements ClassResourceLoader {
9+
10+
private final Class<?> clazz;
11+
12+
DefaultResourceLoader(Class<?> clazz) {
13+
14+
this.clazz = clazz;
15+
}
16+
17+
@Override
18+
public URL getResource(String resourcePath) {
19+
20+
var url = clazz.getResource(resourcePath);
21+
if (url == null) {
22+
// search the module path for top level resource
23+
url =
24+
Optional.ofNullable(ClassLoader.getSystemResource(resourcePath))
25+
.orElseGet(
26+
() -> Thread.currentThread().getContextClassLoader().getResource(resourcePath));
27+
}
28+
return Objects.requireNonNull(url, "Unable to locate resource: " + resourcePath);
29+
}
30+
31+
@Override
32+
public InputStream getResourceAsStream(String resourcePath) {
33+
34+
var url = clazz.getResourceAsStream(resourcePath);
35+
if (url == null) {
36+
// search the module path for top level resource
37+
url =
38+
Optional.ofNullable(ClassLoader.getSystemResourceAsStream(resourcePath))
39+
.orElseGet(
40+
() ->
41+
Thread.currentThread()
42+
.getContextClassLoader()
43+
.getResourceAsStream(resourcePath));
44+
}
45+
return Objects.requireNonNull(url, "Unable to locate resource: " + resourcePath);
46+
}
47+
}

avaje-jex/src/main/java/io/avaje/jex/ExchangeHandler.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.avaje.jex;
22

3+
import java.io.IOException;
4+
35
/**
46
* A handler which is invoked to process HTTP exchanges. Each HTTP exchange is handled by one of
57
* these handlers.
@@ -14,5 +16,5 @@ public interface ExchangeHandler {
1416
* @param ctx the request context containing the request from the client and used to send the
1517
* response
1618
*/
17-
void handle(Context ctx);
19+
void handle(Context ctx) throws IOException;
1820
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package io.avaje.jex;
2+
3+
import java.io.IOException;
4+
import java.net.URL;
5+
import java.nio.file.Paths;
6+
import java.util.Map;
7+
import java.util.function.Predicate;
8+
9+
final class JarResourceHandler extends AbstractStaticHandler implements ExchangeHandler {
10+
11+
private final URL indexFile;
12+
private final URL singleFile;
13+
private final ClassResourceLoader resourceLoader;
14+
15+
JarResourceHandler(
16+
String urlPrefix,
17+
String filesystemRoot,
18+
Map<String, String> mimeTypes,
19+
Map<String, String> headers,
20+
Predicate<Context> skipFilePredicate,
21+
ClassResourceLoader resourceLoader,
22+
URL indexFile,
23+
URL singleFile) {
24+
super(urlPrefix, filesystemRoot, mimeTypes, headers, skipFilePredicate);
25+
26+
this.resourceLoader = resourceLoader;
27+
this.indexFile = indexFile;
28+
this.singleFile = singleFile;
29+
}
30+
31+
@Override
32+
public void handle(Context ctx) throws IOException {
33+
34+
final var jdkExchange = ctx.jdkExchange();
35+
36+
if (singleFile != null) {
37+
sendURL(ctx, singleFile.getPath(), singleFile);
38+
return;
39+
}
40+
41+
if (skipFilePredicate.test(ctx)) {
42+
throw404(jdkExchange);
43+
}
44+
45+
final String wholeUrlPath = jdkExchange.getRequestURI().getPath();
46+
47+
if (wholeUrlPath.endsWith("/") || wholeUrlPath.equals(urlPrefix)) {
48+
sendURL(ctx, indexFile.getPath(), indexFile);
49+
return;
50+
}
51+
52+
final String urlPath = wholeUrlPath.substring(urlPrefix.length());
53+
54+
final String normalizedPath =
55+
Paths.get(filesystemRoot, urlPath).normalize().toString().replace("\\", "/");
56+
57+
if (!normalizedPath.startsWith(filesystemRoot)) {
58+
reportPathTraversal();
59+
}
60+
61+
try (var fis = resourceLoader.getResourceAsStream(normalizedPath)) {
62+
ctx.header("Content-Type", lookupMime(normalizedPath));
63+
ctx.headers(headers);
64+
ctx.write(fis);
65+
} catch (final Exception e) {
66+
throw404(ctx.jdkExchange());
67+
}
68+
}
69+
70+
private void sendURL(Context ctx, String urlPath, URL path) throws IOException {
71+
72+
try (var fis = path.openStream()) {
73+
ctx.header("Content-Type", lookupMime(urlPath));
74+
ctx.headers(headers);
75+
ctx.write(fis);
76+
} catch (final Exception e) {
77+
throw404(ctx.jdkExchange());
78+
}
79+
}
80+
}

avaje-jex/src/main/java/io/avaje/jex/Jex.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package io.avaje.jex;
22

3+
import java.util.Collection;
4+
import java.util.function.Consumer;
5+
36
import io.avaje.inject.BeanScope;
47
import io.avaje.jex.spi.JsonService;
58
import io.avaje.jex.spi.TemplateRender;
69

7-
import java.util.Collection;
8-
import java.util.function.Consumer;
9-
1010
/**
1111
* Create configure and start Jex.
1212
*
@@ -70,6 +70,20 @@ static Jex create() {
7070
*/
7171
Routing routing();
7272

73+
/** Add a static resource route */
74+
default Jex staticResource(StaticContentConfig config) {
75+
routing().get(config.httpPath(), config.createHandler());
76+
return this;
77+
}
78+
79+
/** Add a static resource route using a consumer */
80+
default Jex staticResource(Consumer<StaticContentConfig> consumer) {
81+
var builder = StaticResourceHandlerBuilder.builder();
82+
consumer.accept(builder);
83+
84+
return staticResource(builder);
85+
}
86+
7387
/**
7488
* Set the JsonService.
7589
*/
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package io.avaje.jex;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
import java.util.Map;
7+
import java.util.function.Predicate;
8+
9+
final class PathResourceHandler extends AbstractStaticHandler implements ExchangeHandler {
10+
11+
private final Path indexFile;
12+
private final Path singleFile;
13+
14+
PathResourceHandler(
15+
String urlPrefix,
16+
String filesystemRoot,
17+
Map<String, String> mimeTypes,
18+
Map<String, String> headers,
19+
Predicate<Context> skipFilePredicate,
20+
Path indexFile,
21+
Path singleFile) {
22+
super(urlPrefix, filesystemRoot, mimeTypes, headers, skipFilePredicate);
23+
24+
this.indexFile = indexFile;
25+
this.singleFile = singleFile;
26+
}
27+
28+
@Override
29+
public void handle(Context ctx) throws IOException {
30+
31+
if (singleFile != null) {
32+
sendPathIS(ctx, singleFile.toString(), singleFile);
33+
return;
34+
}
35+
36+
final var jdkExchange = ctx.jdkExchange();
37+
if (skipFilePredicate.test(ctx)) {
38+
throw404(jdkExchange);
39+
}
40+
41+
final String wholeUrlPath = jdkExchange.getRequestURI().getPath();
42+
43+
if (wholeUrlPath.endsWith("/") || wholeUrlPath.equals(urlPrefix)) {
44+
sendPathIS(ctx, indexFile.toString(), indexFile);
45+
46+
return;
47+
}
48+
49+
final String urlPath = wholeUrlPath.substring(urlPrefix.length());
50+
51+
Path path;
52+
try {
53+
path = Path.of(filesystemRoot, urlPath).toRealPath();
54+
55+
} catch (final IOException e) {
56+
// This may be more benign (i.e. not an attack, just a 403),
57+
// but we don't want an attacker to be able to discern the difference.
58+
reportPathTraversal();
59+
return;
60+
}
61+
62+
final String canonicalPath = path.toString();
63+
if (!canonicalPath.startsWith(filesystemRoot)) {
64+
reportPathTraversal();
65+
}
66+
67+
sendPathIS(ctx, urlPath, path);
68+
}
69+
70+
private void sendPathIS(Context ctx, String urlPath, Path path) throws IOException {
71+
final var exchange = ctx.jdkExchange();
72+
final String mimeType = lookupMime(urlPath);
73+
ctx.header("Content-Type", mimeType);
74+
ctx.headers(headers);
75+
exchange.sendResponseHeaders(200, Files.size(path));
76+
try (var fis = Files.newInputStream(path);
77+
var os = exchange.getResponseBody()) {
78+
79+
fis.transferTo(os);
80+
} catch (final Exception e) {
81+
throw404(ctx.jdkExchange());
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)