From 3f148c2f27a5a3eed30fff2ce8d8e88e4e2e66e1 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Mon, 28 Nov 2022 12:34:54 +0100 Subject: [PATCH 1/3] Static content handling rework for Nima. --- .../helidon/common/configurable/LruCache.java | 12 + .../http/junit5/HttpHeaderMatcher.java | 2 +- .../server/ServerCdiExtension.java | 7 + .../staticcontent/CachedHandler.java | 32 ++ .../staticcontent/CachedHandlerInMemory.java | 104 +++++++ .../staticcontent/CachedHandlerJar.java | 79 +++++ .../staticcontent/CachedHandlerPath.java | 83 ++++++ .../staticcontent/CachedHandlerRedirect.java | 48 +++ .../staticcontent/CachedHandlerUrlStream.java | 69 +++++ .../ClassPathContentHandler.java | 275 +++++++++++------- .../FileBasedContentHandler.java | 166 ++++------- .../FileSystemContentHandler.java | 142 ++++++++- .../webserver/staticcontent/IoFunction.java | 24 ++ .../staticcontent/StaticContentHandler.java | 158 +++++++--- .../staticcontent/StaticContentSupport.java | 53 +++- .../src/main/java/module-info.java | 1 + .../staticcontent/CachedHandlerTest.java | 153 ++++++++++ .../StaticContentHandlerTest.java | 19 +- .../test/resources/web/nested/resource.txt | 1 + .../src/test/resources/web/resource.txt | 1 + .../gh4654/Gh4654StaticContentTest.java | 4 +- 21 files changed, 1154 insertions(+), 279 deletions(-) create mode 100644 nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandler.java create mode 100644 nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerInMemory.java create mode 100644 nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerJar.java create mode 100644 nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerPath.java create mode 100644 nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerRedirect.java create mode 100644 nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerUrlStream.java create mode 100644 nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/IoFunction.java create mode 100644 nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java create mode 100644 nima/webserver/static-content/src/test/resources/web/nested/resource.txt create mode 100644 nima/webserver/static-content/src/test/resources/web/resource.txt diff --git a/common/configurable/src/main/java/io/helidon/common/configurable/LruCache.java b/common/configurable/src/main/java/io/helidon/common/configurable/LruCache.java index f5b6bd40924..7a436ba0003 100644 --- a/common/configurable/src/main/java/io/helidon/common/configurable/LruCache.java +++ b/common/configurable/src/main/java/io/helidon/common/configurable/LruCache.java @@ -207,6 +207,18 @@ public int capacity() { return capacity; } + /** + * Clear all records in the cache. + */ + public void clear() { + writeLock.lock(); + try { + backingMap.clear(); + } finally { + writeLock.unlock(); + } + } + // for unit testing V directGet(K key) { return backingMap.get(key); diff --git a/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/HttpHeaderMatcher.java b/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/HttpHeaderMatcher.java index 9a7157b5f6a..85962ad231a 100644 --- a/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/HttpHeaderMatcher.java +++ b/common/testing/http-junit5/src/main/java/io/helidon/common/testing/http/junit5/HttpHeaderMatcher.java @@ -191,7 +191,7 @@ protected void describeMismatchSafely(Headers item, Description mismatchDescript mismatchDescription.appendValue(name.defaultCase()).appendText(" header is present with wrong values "); valuesMatcher.describeMismatch(all, mismatchDescription); } else { - mismatchDescription.appendValue(name.defaultCase()).appendText("header is not present"); + mismatchDescription.appendValue(name.defaultCase()).appendText(" header is not present"); } } } diff --git a/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java b/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java index 154357803c1..42f950eba6a 100644 --- a/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java +++ b/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java @@ -447,6 +447,13 @@ private void registerClasspathStaticContent(Config config) { config.get("tmp-dir") .as(Path.class) .ifPresent(cpBuilder::tmpDir); + + config.get("cache-in-memory") + .asList(String.class) + .stream() + .flatMap(List::stream) + .forEach(cpBuilder::addCacheInMemory); + StaticContentSupport staticContent = cpBuilder.build(); if (context.exists()) { diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandler.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandler.java new file mode 100644 index 00000000000..f196e57874a --- /dev/null +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandler.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.staticcontent; + +import java.io.IOException; + +import io.helidon.common.configurable.LruCache; +import io.helidon.common.http.Http; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +interface CachedHandler { + boolean handle(LruCache cache, + Http.Method method, + ServerRequest request, + ServerResponse response, + String requestedResource) throws IOException; +} diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerInMemory.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerInMemory.java new file mode 100644 index 00000000000..d61415dc912 --- /dev/null +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerInMemory.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.staticcontent; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.function.BiConsumer; + +import io.helidon.common.configurable.LruCache; +import io.helidon.common.http.Http; +import io.helidon.common.http.HttpException; +import io.helidon.common.http.ServerRequestHeaders; +import io.helidon.common.http.ServerResponseHeaders; +import io.helidon.common.media.type.MediaType; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processEtag; +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processModifyHeaders; + +record CachedHandlerInMemory(MediaType mediaType, + Instant lastModified, + BiConsumer setLastModifiedHeader, + byte[] bytes, + int contentLength, + Http.HeaderValue contentLengthHeader) implements CachedHandler { + + @Override + public boolean handle(LruCache cache, + Http.Method method, + ServerRequest request, + ServerResponse response, + String requestedResource) { + // etag etc. + if (lastModified != null) { + processEtag(String.valueOf(lastModified.toEpochMilli()), request.headers(), response.headers()); + processModifyHeaders(lastModified, request.headers(), response.headers(), setLastModifiedHeader); + } + + response.headers().contentType(mediaType); + + if (method == Http.Method.GET) { + send(request, response); + } else { + response.headers().set(contentLengthHeader()); + response.send(); + } + + return true; + } + + private void send(ServerRequest request, ServerResponse response) { + ServerRequestHeaders headers = request.headers(); + + if (headers.contains(Http.Header.RANGE)) { + long contentLength = contentLength(); + List ranges = ByteRangeRequest.parse(request, + response, + headers.get(Http.Header.RANGE).values(), + contentLength); + if (ranges.size() == 1) { + // single response + ByteRangeRequest range = ranges.get(0); + + if (range.offset() > contentLength()) { + throw new HttpException("Invalid range offset", Http.Status.REQUESTED_RANGE_NOT_SATISFIABLE_416, true); + } + if (range.length() > (contentLength() - range.offset())) { + throw new HttpException("Invalid length", Http.Status.REQUESTED_RANGE_NOT_SATISFIABLE_416, true); + } + + range.setContentRange(response); + + // only send a part of the file + response.send(Arrays.copyOfRange(bytes(), (int) range.offset(), (int) range.length())); + } else { + // not supported, send full + send(response); + } + } else { + send(response); + } + } + + private void send(ServerResponse response) { + response.headers().set(contentLengthHeader()); + response.send(bytes()); + } +} diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerJar.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerJar.java new file mode 100644 index 00000000000..377df71ff6e --- /dev/null +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerJar.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.helidon.nima.webserver.staticcontent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.function.BiConsumer; + +import io.helidon.common.configurable.LruCache; +import io.helidon.common.http.Http; +import io.helidon.common.http.ServerResponseHeaders; +import io.helidon.common.media.type.MediaType; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +import static io.helidon.nima.webserver.staticcontent.FileBasedContentHandler.contentLength; +import static io.helidon.nima.webserver.staticcontent.FileBasedContentHandler.send; +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processEtag; +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processModifyHeaders; + +record CachedHandlerJar(Path path, + MediaType mediaType, + Instant lastModified, + BiConsumer setLastModifiedHeader) implements CachedHandler { + private static final System.Logger LOGGER = System.getLogger(CachedHandlerJar.class.getName()); + + @Override + public boolean handle(LruCache cache, + Http.Method method, + ServerRequest request, + ServerResponse response, + String requestedResource) throws IOException { + + // check if file still exists (the tmp may have been removed, file may have been removed + // there is still a race change, but we do not want to keep cached records for invalid files + if (!Files.exists(path)) { + cache.remove(requestedResource); + return false; + } + + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + LOGGER.log(System.Logger.Level.TRACE, "Sending static content from jar: " + requestedResource); + } + + // etag etc. + if (lastModified != null) { + processEtag(String.valueOf(lastModified.toEpochMilli()), request.headers(), response.headers()); + processModifyHeaders(lastModified, request.headers(), response.headers(), setLastModifiedHeader()); + } + + response.headers().contentType(mediaType); + + if (method == Http.Method.GET) { + send(request, response, path); + } else { + response.headers().contentLength(contentLength(path)); + response.send(); + } + + return true; + } +} diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerPath.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerPath.java new file mode 100644 index 00000000000..79da93b4c97 --- /dev/null +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerPath.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.staticcontent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import java.util.function.BiConsumer; + +import io.helidon.common.configurable.LruCache; +import io.helidon.common.http.ForbiddenException; +import io.helidon.common.http.Http; +import io.helidon.common.http.ServerResponseHeaders; +import io.helidon.common.media.type.MediaType; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +import static io.helidon.nima.webserver.staticcontent.FileBasedContentHandler.processContentLength; +import static io.helidon.nima.webserver.staticcontent.FileBasedContentHandler.send; +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processEtag; +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processModifyHeaders; + +record CachedHandlerPath(Path path, + MediaType mediaType, + IoFunction> lastModified, + BiConsumer setLastModifiedHeader) implements CachedHandler { + private static final System.Logger LOGGER = System.getLogger(CachedHandlerPath.class.getName()); + + @Override + public boolean handle(LruCache cache, + Http.Method method, + ServerRequest request, + ServerResponse response, + String requestedResource) throws IOException { + + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + LOGGER.log(System.Logger.Level.TRACE, "Sending static content from path: " + path); + } + + // now it exists and is a file + if (!Files.exists(path) || !Files.isRegularFile(path) || !Files.isReadable(path) || Files.isHidden(path)) { + // check if file still exists (the tmp may have been removed, file may have been removed + // there is still a race change, but we do not want to keep cached records for invalid files + cache.remove(requestedResource); + throw new ForbiddenException("File is not accessible"); + } + + Instant lastModified = lastModified().apply(path).orElse(null); + + // etag etc. + if (lastModified != null) { + processEtag(String.valueOf(lastModified.toEpochMilli()), request.headers(), response.headers()); + processModifyHeaders(lastModified, request.headers(), response.headers(), setLastModifiedHeader()); + } + + response.headers().contentType(mediaType); + + if (method == Http.Method.GET) { + send(request, response, path); + } else { + processContentLength(path, response.headers()); + response.send(); + } + + return true; + } +} diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerRedirect.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerRedirect.java new file mode 100644 index 00000000000..3a25d43415a --- /dev/null +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerRedirect.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.staticcontent; + +import java.io.IOException; + +import io.helidon.common.configurable.LruCache; +import io.helidon.common.http.Http; +import io.helidon.common.uri.UriQuery; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +record CachedHandlerRedirect(String location) implements CachedHandler { + @Override + public boolean handle(LruCache cache, + Http.Method method, + ServerRequest request, + ServerResponse response, + String requestedResource) throws IOException { + + UriQuery query = request.query(); + String locationWithQuery; + if (query.isEmpty()) { + locationWithQuery = location(); + } else { + locationWithQuery = location() + "?" + query.rawValue(); + } + + response.status(Http.Status.MOVED_PERMANENTLY_301); + response.headers().set(Http.Header.LOCATION, locationWithQuery); + response.send(); + return true; + } +} diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerUrlStream.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerUrlStream.java new file mode 100644 index 00000000000..1cbff0e7c48 --- /dev/null +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/CachedHandlerUrlStream.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.staticcontent; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLConnection; +import java.time.Instant; + +import io.helidon.common.configurable.LruCache; +import io.helidon.common.http.Http; +import io.helidon.common.media.type.MediaType; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processEtag; +import static io.helidon.nima.webserver.staticcontent.StaticContentHandler.processModifyHeaders; + +record CachedHandlerUrlStream(MediaType mediaType, URL url) implements CachedHandler { + private static final System.Logger LOGGER = System.getLogger(CachedHandlerUrlStream.class.getName()); + + @Override + public boolean handle(LruCache cache, + Http.Method method, + ServerRequest request, + ServerResponse response, + String requestedResource) throws IOException { + + if (LOGGER.isLoggable(System.Logger.Level.DEBUG)) { + LOGGER.log(System.Logger.Level.DEBUG, "Sending static content using stream from classpath: " + url); + } + + URLConnection urlConnection = url.openConnection(); + long lastModified = urlConnection.getLastModified(); + + if (lastModified != 0) { + processEtag(String.valueOf(lastModified), request.headers(), response.headers()); + processModifyHeaders(Instant.ofEpochMilli(lastModified), request.headers(), response.headers()); + } + + response.headers().contentType(mediaType); + + if (method == Http.Method.HEAD) { + response.send(); + return true; + } + + try (InputStream in = url.openStream(); OutputStream outputStream = response.outputStream()) { + in.transferTo(outputStream); + } + return true; + } +} diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/ClassPathContentHandler.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/ClassPathContentHandler.java index 2fb004d280a..e2c04769638 100644 --- a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/ClassPathContentHandler.java +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/ClassPathContentHandler.java @@ -16,28 +16,32 @@ package io.helidon.nima.webserver.staticcontent; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.lang.System.Logger.Level; import java.net.JarURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.time.Instant; +import java.util.HashSet; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiFunction; import java.util.jar.JarEntry; import java.util.jar.JarFile; import io.helidon.common.http.Http; import io.helidon.common.http.InternalServerException; +import io.helidon.common.media.type.MediaType; import io.helidon.nima.webserver.http.ServerRequest; import io.helidon.nima.webserver.http.ServerResponse; @@ -47,10 +51,12 @@ class ClassPathContentHandler extends FileBasedContentHandler { private static final System.Logger LOGGER = System.getLogger(ClassPathContentHandler.class.getName()); + private final AtomicBoolean populatedInMemoryCache = new AtomicBoolean(); private final ClassLoader classLoader; private final String root; private final String rootWithTrailingSlash; private final BiFunction tmpFile; + private final Set cacheInMemory; // URL's hash code and equal are not suitable for map or set private final Map extracted = new ConcurrentHashMap<>(); @@ -59,6 +65,7 @@ class ClassPathContentHandler extends FileBasedContentHandler { super(builder); this.classLoader = builder.classLoader(); + this.cacheInMemory = new HashSet<>(builder.cacheInMemory()); this.root = builder.root(); this.rootWithTrailingSlash = root + '/'; @@ -92,109 +99,156 @@ static String fileName(URL url) { return path; } + @Override + public void beforeStart() { + if (populatedInMemoryCache.compareAndSet(false, true)) { + for (String resource : cacheInMemory) { + try { + addToInMemoryCache(resource); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to add file to in-memory cache", e); + } + } + } + super.beforeStart(); + } + + @Override + void releaseCache() { + populatedInMemoryCache.set(false); + } + @SuppressWarnings("checkstyle:RegexpSinglelineJava") @Override boolean doHandle(Http.Method method, String requestedPath, ServerRequest request, ServerResponse response) throws IOException, URISyntaxException { - String resource = requestedPath.isEmpty() ? root : (rootWithTrailingSlash + requestedPath); + String rawPath = request.prologue().uriPath().rawPath(); + String requestedResource = requestedResource(rawPath, requestedPath); - if (LOGGER.isLoggable(Level.TRACE)) { - LOGGER.log(Level.TRACE, "Requested class path resource: " + resource); + if (!requestedResource.equals(root) && !requestedResource.startsWith(rootWithTrailingSlash)) { + // trying to get path outside of project root (such as requesting ../../etc/hosts) + return false; } - // this MUST be done, so we do not escape the bounds of configured directory - // We use multi-arg constructor so it performs url encoding - URI myuri = new URI(null, null, resource, null); - String requestedResource = myuri.normalize().getPath(); + // we have a resource that we support, let's try to use one from the cache + Optional cached = cacheHandler(requestedResource); - if (!requestedResource.equals(root) && !requestedResource.startsWith(rootWithTrailingSlash)) { - return false; + if (cached.isPresent()) { + // this requested resource is cached and can be safely returned + return cached.get().handle(handlerCache(), method, request, response, requestedResource); } + // if it is not cached, find the resource and cache it (or return 404 and do not cache) + // try to find the resource on classpath (cannot use root URL and then resolve, as root and sub-resource // may be from different jar files/directories - URL url = classLoader.getResource(resource); + URL url = classLoader.getResource(requestedResource); String welcomeFileName = welcomePageName(); - if (null != welcomeFileName) { - String welcomeFileResource = requestedResource + "/" + welcomeFileName; + if (welcomeFileName != null) { + String welcomeFileResource = requestedResource + + (requestedResource.endsWith("/") ? "" : "/") + + welcomeFileName; URL welcomeUrl = classLoader.getResource(welcomeFileResource); - if (null != welcomeUrl) { + if (welcomeUrl != null) { // there is a welcome file under requested resource, ergo requested resource was a directory - String rawFullPath = request.prologue().uriPath().rawPath(); - if (rawFullPath.endsWith("/")) { + if (rawPath.endsWith("/")) { // this is OK, as the path ends with a forward slash + + // first check if this is an in-memory resource + Optional inMemoryMaybe = cacheInMemory(welcomeFileResource); + if (inMemoryMaybe.isPresent()) { + // reference to the same definition, never times out + cacheInMemory(requestedResource, inMemoryMaybe.get()); + return inMemoryMaybe.get().handle(handlerCache(), + method, + request, + response, + requestedResource); + } + url = welcomeUrl; } else { // must redirect - redirect(request, response, rawFullPath + "/"); - return true; + String redirectLocation = rawPath + "/"; + CachedHandlerRedirect handler = new CachedHandlerRedirect(redirectLocation); + cacheHandler(requestedResource, handler); + return handler.handle(handlerCache(), method, request, response, requestedResource); } } } - if (url == null) { + if (url == null || url.getPath().endsWith("/")) { if (LOGGER.isLoggable(Level.TRACE)) { - LOGGER.log(Level.TRACE, "Requested resource " + resource + " does not exist"); + LOGGER.log(Level.TRACE, "Requested resource " + requestedResource + + " does not exist or is a directory without welcome file."); } + // not caching 404, to prevent intentional cache pollution by users return false; } - URL logUrl = url; // need to be effectively final to use in lambda if (LOGGER.isLoggable(Level.TRACE)) { - LOGGER.log(Level.TRACE, "Located resource url. Resource: " + resource + ", URL: " + logUrl); + LOGGER.log(Level.TRACE, "Located resource url. Resource: " + requestedResource + ", URL: " + url); } // now read the URL - we have direct support for files and jar files, others are handled by stream only - switch (url.getProtocol()) { - case "file": - sendFile(method, Paths.get(url.toURI()), request, response, welcomePageName()); - break; - case "jar": - return sendJar(method, requestedResource, url, request, response); - default: - sendUrlStream(method, url, request, response); - break; + Optional handler = switch (url.getProtocol()) { + case "file" -> fileHandler(Paths.get(url.toURI())); + case "jar" -> jarHandler(requestedResource, url); + default -> urlStreamHandler(url); + }; + + if (handler.isEmpty()) { + return false; } - return true; + CachedHandler cachedHandler = handler.get(); + cacheHandler(requestedResource, cachedHandler); + + return cachedHandler.handle(handlerCache(), method, request, response, requestedResource); } - boolean sendJar(Http.Method method, - String requestedResource, - URL url, - ServerRequest request, - ServerResponse response) throws IOException { + private String requestedResource(String rawPath, String requestedPath) throws URISyntaxException { + String resource = requestedPath.isEmpty() ? root : (rootWithTrailingSlash + requestedPath); if (LOGGER.isLoggable(Level.TRACE)) { - LOGGER.log(Level.TRACE, "Sending static content from classpath: " + url); + LOGGER.log(Level.TRACE, "Requested class path resource: " + resource); } - ExtractedJarEntry extrEntry = extracted - .compute(requestedResource, (key, entry) -> existOrCreate(url, entry)); - if (extrEntry.tempFile == null) { - return false; - } - if (extrEntry.lastModified != null) { - processEtag(String.valueOf(extrEntry.lastModified.toEpochMilli()), request.headers(), response.headers()); - processModifyHeaders(extrEntry.lastModified, request.headers(), response.headers()); - } + // this MUST be done, so we do not escape the bounds of configured directory + // We use multi-arg constructor so it performs url encoding + URI myuri = new URI(null, null, resource, null); - String entryName = (extrEntry.entryName == null) ? fileName(url) : extrEntry.entryName; + String result = myuri.normalize().getPath(); + return rawPath.endsWith("/") ? result + "/" : result; + } - processContentType(entryName, - request.headers(), - response.headers()); + private Optional jarHandler(String requestedResource, URL url) { + ExtractedJarEntry extrEntry = extracted.compute(requestedResource, (key, entry) -> existOrCreate(url, entry)); - if (method == Http.Method.HEAD) { - processContentLength(extrEntry.tempFile, response.headers()); - response.send(); - } else { - send(request, response, extrEntry.tempFile); + if (extrEntry.tempFile == null) { + // once again, not caching 404 + return Optional.empty(); } - return true; + Instant lastModified = extrEntry.lastModified(); + if (lastModified == null) { + return Optional.of(new CachedHandlerJar(extrEntry.tempFile, + detectType(extrEntry.entryName), + null, + null)); + } else { + // we can cache this, as this is a jar record + Http.HeaderValue lastModifiedHeader = Http.Header.create(Http.Header.LAST_MODIFIED, + true, + false, + formatLastModified(lastModified)); + return Optional.of(new CachedHandlerJar(extrEntry.tempFile, + detectType(extrEntry.entryName), + extrEntry.lastModified(), + (headers, instant) -> headers.set(lastModifiedHeader))); + } } private ExtractedJarEntry existOrCreate(URL url, ExtractedJarEntry entry) { @@ -210,29 +264,8 @@ private ExtractedJarEntry existOrCreate(URL url, ExtractedJarEntry entry) { return entry; } - private void sendUrlStream(Http.Method method, URL url, ServerRequest request, ServerResponse response) - throws IOException { - - LOGGER.log(Level.DEBUG, "Sending static content using stream from classpath: " + url); - - URLConnection urlConnection = url.openConnection(); - long lastModified = urlConnection.getLastModified(); - - if (lastModified != 0) { - processEtag(String.valueOf(lastModified), request.headers(), response.headers()); - processModifyHeaders(Instant.ofEpochMilli(lastModified), request.headers(), response.headers()); - } - - processContentType(fileName(url), request.headers(), response.headers()); - - if (method == Http.Method.HEAD) { - response.send(); - return; - } - - try (InputStream in = url.openStream(); OutputStream outputStream = response.outputStream()) { - in.transferTo(outputStream); - } + private Optional urlStreamHandler(URL url) { + return Optional.of(new CachedHandlerUrlStream(detectType(fileName(url)), url)); } private ExtractedJarEntry extractJarEntry(URL url) { @@ -243,13 +276,13 @@ private ExtractedJarEntry extractJarEntry(URL url) { if (jarEntry.isDirectory()) { return new ExtractedJarEntry(jarEntry.getName()); // a directory } - Instant lastModified = getLastModified(jarFile.getName()); + Optional lastModified = lastModified(jarFile.getName()); // Extract JAR entry to file try (InputStream is = jarFile.getInputStream(jarEntry)) { Path tempFile = tmpFile.apply("ws", ".je"); Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); - return new ExtractedJarEntry(tempFile, lastModified, jarEntry.getName()); + return new ExtractedJarEntry(tempFile, lastModified.orElse(null), jarEntry.getName()); } finally { if (!jarUrlConnection.getUseCaches()) { jarFile.close(); @@ -260,34 +293,74 @@ private ExtractedJarEntry extractJarEntry(URL url) { } } - private Instant getLastModified(String path) throws IOException { - Path file = Paths.get(path); + private void addToInMemoryCache(String resource) throws IOException, URISyntaxException { + /* + we need to know: + - content size + - media type + - last modified timestamp + - content + */ - if (Files.exists(file) && Files.isRegularFile(file)) { - return Files.getLastModifiedTime(file).toInstant(); - } else { - return null; + String requestedResource; + try { + requestedResource = requestedResource("", resource); + } catch (URISyntaxException e) { + LOGGER.log(Level.WARNING, "Resource " + resource + " cannot be added to in memory cache, as it is not a valid" + + " identifier", e); + return; } - } - private static class ExtractedJarEntry { - private final Path tempFile; - private final Instant lastModified; - private final String entryName; + if (!requestedResource.equals(root) && !requestedResource.startsWith(rootWithTrailingSlash)) { + LOGGER.log(Level.WARNING, "Resource " + resource + " cannot be added to in memory cache, as it is not within" + + " the resource root directory."); + return; + } + + URL url = classLoader.getResource(requestedResource); + if (url == null) { + LOGGER.log(Level.WARNING, "Resource " + resource + " cannot be added to in memory cache, as it does " + + "not exist on classpath"); + return; + } - ExtractedJarEntry(Path tempFile, Instant lastModified, String entryName) { - this.tempFile = tempFile; - this.lastModified = lastModified; - this.entryName = entryName; + // now we do have a resource, and we want to load it into memory + // we are not checking the size, as this is explicitly configured by the user, and if we run out of memory, we just do... + Optional lastModified = lastModified(url); + MediaType contentType = detectType(fileName(url)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (InputStream in = url.openStream()) { + in.transferTo(baos); } + byte[] entityBytes = baos.toByteArray(); + + cacheInMemory(requestedResource, contentType, entityBytes, lastModified); + } + + private Optional lastModified(URL url) throws URISyntaxException, IOException { + return switch (url.getProtocol()) { + case "file" -> lastModified(Paths.get(url.toURI())); + case "jar" -> lastModifiedFromJar(url); + default -> Optional.empty(); + }; + } + + private Optional lastModifiedFromJar(URL url) throws IOException { + JarURLConnection jarUrlConnection = (JarURLConnection) url.openConnection(); + JarFile jarFile = jarUrlConnection.getJarFile(); + return lastModified(jarFile.getName()); + } + + private Optional lastModified(String path) throws IOException { + return lastModified(Paths.get(path)); + } + private record ExtractedJarEntry(Path tempFile, Instant lastModified, String entryName) { /** * Creates directory representation. */ ExtractedJarEntry(String entryName) { - this.tempFile = null; - this.lastModified = null; - this.entryName = entryName; + this(null, null, entryName); } } } diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileBasedContentHandler.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileBasedContentHandler.java index c156d82b4d9..2f98b22dfe4 100644 --- a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileBasedContentHandler.java +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileBasedContentHandler.java @@ -32,12 +32,7 @@ import java.util.Objects; import java.util.Optional; -import io.helidon.common.http.ForbiddenException; -import io.helidon.common.http.Http; import io.helidon.common.http.Http.Header; -import io.helidon.common.http.Http.HeaderValues; -import io.helidon.common.http.HttpException; -import io.helidon.common.http.HttpMediaType; import io.helidon.common.http.ServerRequestHeaders; import io.helidon.common.http.ServerResponseHeaders; import io.helidon.common.media.type.MediaType; @@ -46,8 +41,6 @@ import io.helidon.nima.webserver.http.ServerResponse; abstract class FileBasedContentHandler extends StaticContentHandler { - private static final System.Logger LOGGER = System.getLogger(FileBasedContentHandler.class.getName()); - private final Map customMediaTypes; FileBasedContentHandler(StaticContentSupport.FileBasedBuilder builder) { @@ -66,88 +59,11 @@ static String fileName(Path path) { return fileName.toString(); } - /** - * Determines and set a Content-Type header based on filename extension. - * - * @param filename a filename - * @param requestHeaders an HTTP request headers - * @param responseHeaders an HTTP response headers - */ - void processContentType(String filename, - ServerRequestHeaders requestHeaders, - ServerResponseHeaders responseHeaders) { - // Try to get Content-Type - responseHeaders.contentType(detectType(filename, requestHeaders)); - } - - Optional findCustomMediaType(String fileName) { - int ind = fileName.lastIndexOf('.'); - - if (ind < 0) { - return Optional.empty(); - } - - String fileSuffix = fileName.substring(ind + 1); - - return Optional.ofNullable(customMediaTypes.get(fileSuffix)); - } - - void sendFile(Http.Method method, - Path pathParam, - ServerRequest request, - ServerResponse response, - String welcomePage) - throws IOException { - - if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { - LOGGER.log(System.Logger.Level.TRACE, "Sending static content from file: " + pathParam); - } - - Path path = pathParam; - // we know the file exists, though it may be a directory - //First doHandle a directory case - if (Files.isDirectory(path)) { - String rawFullPath = request.prologue().uriPath().rawPath(); - if (rawFullPath.endsWith("/")) { - // Try to found welcome file - path = resolveWelcomeFile(path, welcomePage); - } else { - // Or redirect to slash ended - redirect(request, response, rawFullPath + "/"); - return; - } - } - - // now it exists and is a file - if (!Files.isRegularFile(path) || !Files.isReadable(path) || Files.isHidden(path)) { - throw new ForbiddenException("File is not accessible"); - } - - // Caching headers support - try { - Instant lastMod = Files.getLastModifiedTime(path).toInstant(); - processEtag(String.valueOf(lastMod.toEpochMilli()), request.headers(), response.headers()); - processModifyHeaders(lastMod, request.headers(), response.headers()); - } catch (IOException | SecurityException e) { - // Cannot get mod time or size - well, we cannot tell if it was modified or not. Don't support cache headers - } - - processContentType(fileName(path), request.headers(), response.headers()); - - if (method == Http.Method.HEAD) { - processContentLength(path, response.headers()); - response.header(HeaderValues.ACCEPT_RANGES_BYTES) - .send(); - } else { - send(request, response, path); - } - } - - void processContentLength(Path path, ServerResponseHeaders headers) { + static void processContentLength(Path path, ServerResponseHeaders headers) { headers.set(Header.create(Header.CONTENT_LENGTH, contentLength(path))); } - long contentLength(Path path) { + static long contentLength(Path path) { try { return Files.size(path); } catch (IOException e) { @@ -155,7 +71,7 @@ long contentLength(Path path) { } } - void send(ServerRequest request, ServerResponse response, Path path) throws IOException { + static void send(ServerRequest request, ServerResponse response, Path path) throws IOException { ServerRequestHeaders headers = request.headers(); if (headers.contains(Header.RANGE)) { long contentLength = contentLength(path); @@ -201,6 +117,53 @@ void send(ServerRequest request, ServerResponse response, Path path) throws IOEx } } + Optional findCustomMediaType(String fileName) { + int ind = fileName.lastIndexOf('.'); + + if (ind < 0) { + return Optional.empty(); + } + + String fileSuffix = fileName.substring(ind + 1); + + return Optional.ofNullable(customMediaTypes.get(fileSuffix)); + } + + Optional fileHandler(Path path) { + // we know the file exists and is a file + return Optional.of(new CachedHandlerPath(path, + detectType(fileName(path)), + FileBasedContentHandler::lastModified, + ServerResponseHeaders::lastModified)); + } + + MediaType detectType(String fileName) { + Objects.requireNonNull(fileName); + + // first try to see if we have an override + // then find if we have a detected type + /* + From HTTP/1.1 specification of status codes: + Note: HTTP/1.1 servers are allowed to return responses which are + not acceptable according to the accept headers sent in the + request. In some cases, this may even be preferable to sending a + 406 response. User agents are encouraged to inspect the headers of + an incoming response to determine if it is acceptable. + The 415 we used before is for the case when request entity does not match the method, so wrong here + If we cannot identify a media type, just use octet stream (just bytes....) + */ + return findCustomMediaType(fileName) + .or(() -> MediaTypes.detectType(fileName)) + .orElse(MediaTypes.APPLICATION_OCTET_STREAM); + } + + static Optional lastModified(Path path) throws IOException { + if (Files.exists(path) && Files.isRegularFile(path)) { + return Optional.of(Files.getLastModifiedTime(path).toInstant()); + } + return Optional.empty(); + } + /** * Find welcome file in provided directory or throw not found {@link io.helidon.common.http.RequestException}. * @@ -209,38 +172,11 @@ void send(ServerRequest request, ServerResponse response, Path path) throws IOEx * @return a path of the welcome file * @throws io.helidon.common.http.RequestException if welcome file doesn't exists */ - private static Path resolveWelcomeFile(Path directory, String name) { + static Path resolveWelcomeFile(Path directory, String name) { throwNotFoundIf(name == null || name.isEmpty()); Path result = directory.resolve(name); throwNotFoundIf(!Files.exists(result)); return result; } - private MediaType detectType(String fileName, ServerRequestHeaders requestHeaders) { - Objects.requireNonNull(fileName); - Objects.requireNonNull(requestHeaders); - - // first try to see if we have an override - // then find if we have a detected type - // then check the type is accepted by the request - return findCustomMediaType(fileName) - .or(() -> MediaTypes.detectType(fileName)) - .map(it -> { - if (requestHeaders.isAccepted(it)) { - return it; - } - throw new HttpException("Media type " + it + " is not accepted by request", - Http.Status.UNSUPPORTED_MEDIA_TYPE_415, - true); - }) - .orElseGet(() -> { - List acceptedTypes = requestHeaders.acceptedTypes(); - if (acceptedTypes.isEmpty()) { - return MediaTypes.APPLICATION_OCTET_STREAM; - } else { - return acceptedTypes.iterator().next().mediaType(); - } - }); - } - } diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java index 8c2ffde03b8..baf2c98628b 100644 --- a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java @@ -18,10 +18,16 @@ import java.io.IOException; import java.lang.System.Logger.Level; +import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import io.helidon.common.http.Http; +import io.helidon.common.http.ServerResponseHeaders; import io.helidon.nima.webserver.http.ServerRequest; import io.helidon.nima.webserver.http.ServerResponse; @@ -31,42 +37,146 @@ class FileSystemContentHandler extends FileBasedContentHandler { private static final System.Logger LOGGER = System.getLogger(FileSystemContentHandler.class.getName()); + private final AtomicBoolean populatedInMemoryCache = new AtomicBoolean(); private final Path root; + private final Set cacheInMemory; FileSystemContentHandler(StaticContentSupport.FileSystemBuilder builder) { super(builder); this.root = builder.root(); + this.cacheInMemory = new HashSet<>(builder.cacheInMemory()); } @Override - boolean doHandle(Http.Method method, String requestedPath, ServerRequest request, ServerResponse response) - throws IOException { - Path resolved; - if (requestedPath.isEmpty()) { - resolved = root; - } else { - resolved = root.resolve(requestedPath).normalize(); - if (LOGGER.isLoggable(Level.DEBUG)) { - LOGGER.log(Level.DEBUG, "Requested file: " + resolved.toAbsolutePath()); - } - if (!resolved.startsWith(root)) { - return false; + public void beforeStart() { + if (populatedInMemoryCache.compareAndSet(false, true)) { + for (String resource : cacheInMemory) { + try { + addToInMemoryCache(resource); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to add file to in-memory cache", e); + } } } + super.beforeStart(); + } + + @Override + void releaseCache() { + populatedInMemoryCache.set(false); + } + + @Override + boolean doHandle(Http.Method method, String requestedPath, ServerRequest req, ServerResponse res) throws IOException { + Path path = requestedPath(requestedPath); + if (LOGGER.isLoggable(Level.DEBUG)) { + LOGGER.log(Level.DEBUG, "Requested file: " + path.toAbsolutePath()); + } + if (!path.startsWith(root)) { + return false; + } + + String rawPath = req.path().rawPath(); + + String relativePath = root.relativize(path).toString(); + String requestedResource = rawPath.endsWith("/") ? relativePath + "/" : relativePath; + + // we have a resource that we support, let's try to use one from the cache + Optional cached = cacheHandler(requestedResource); + + if (cached.isPresent()) { + // this requested resource is cached and can be safely returned + return cached.get().handle(handlerCache(), method, req, res, requestedResource); + } - return doHandle(method, resolved, request, response); + // if it is not cached, find the resource and cache it (or return 404 and do not cache) + return doHandle(method, requestedResource, req, res, rawPath, path); } - boolean doHandle(Http.Method method, Path path, ServerRequest request, ServerResponse response) throws IOException { + boolean doHandle(Http.Method method, + String requestedResource, + ServerRequest req, + ServerResponse res, + String rawPath, + Path path) throws IOException { + // Check existence if (!Files.exists(path)) { + // not caching 404 return false; } - sendFile(method, path, request, response, welcomePageName()); + // we know the file exists, though it may be a directory + // First doHandle a directory case + String welcomeFileName = welcomePageName(); + if (welcomeFileName != null) { + if (Files.isDirectory(path)) { + String welcomeFileResource = requestedResource + + (requestedResource.endsWith("/") ? "" : "/") + + welcomeFileName; + + if (rawPath.endsWith("/")) { + Optional inMemoryMaybe = cacheInMemory(welcomeFileResource); + if (inMemoryMaybe.isPresent()) { + // reference to the same definition, never times out + cacheInMemory(requestedResource, inMemoryMaybe.get()); + return inMemoryMaybe.get().handle(handlerCache(), + method, + req, + res, + requestedResource); + } + + // Try to find welcome file + path = resolveWelcomeFile(path, welcomePageName()); + } else { + // Or redirect to slash ended + String redirectLocation = rawPath + "/"; + CachedHandlerRedirect handler = new CachedHandlerRedirect(redirectLocation); + cacheHandler(requestedResource, handler); + return handler.handle(handlerCache(), method, req, res, requestedResource); + } + } + } + + CachedHandler handler = new CachedHandlerPath(path, + detectType(fileName(path)), + FileBasedContentHandler::lastModified, + ServerResponseHeaders::lastModified); + cacheHandler(requestedResource, handler); + return handler.handle(handlerCache(), method, req, res, requestedResource); + } + + private void addToInMemoryCache(String resource) throws IOException, URISyntaxException { + /* + we need to know: + - content size + - media type + - last modified timestamp + - content + */ + Path path = requestedPath(resource); + if (!path.startsWith(root)) { + LOGGER.log(Level.WARNING, "File " + resource + " cannot be added to in memory cache, as it is not within" + + " the root directory."); + return; + } + + if (!Files.exists(path)) { + LOGGER.log(Level.WARNING, "File " + resource + " cannot be added to in memory cache, as it does not exist"); + return; + } + + byte[] fileBytes = Files.readAllBytes(path); - return true; + cacheInMemory(resource, detectType(fileName(path)), fileBytes, lastModified(path)); } + private Path requestedPath(String requestedPath) { + if (requestedPath.isEmpty()) { + return root; + } + return root.resolve(requestedPath).normalize(); + } } diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/IoFunction.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/IoFunction.java new file mode 100644 index 00000000000..b08d2f93a98 --- /dev/null +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/IoFunction.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.staticcontent; + +import java.io.IOException; + +@FunctionalInterface +interface IoFunction { + R apply(P param) throws IOException; +} diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentHandler.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentHandler.java index 51973659e71..ba11bceb33f 100644 --- a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentHandler.java +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentHandler.java @@ -20,11 +20,18 @@ import java.lang.System.Logger.Level; import java.net.URISyntaxException; import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.chrono.ChronoZonedDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import java.util.function.Function; +import io.helidon.common.configurable.LruCache; import io.helidon.common.http.Http; import io.helidon.common.http.Http.Header; import io.helidon.common.http.HttpException; @@ -33,7 +40,7 @@ import io.helidon.common.http.PathMatchers; import io.helidon.common.http.ServerRequestHeaders; import io.helidon.common.http.ServerResponseHeaders; -import io.helidon.common.uri.UriQuery; +import io.helidon.common.media.type.MediaType; import io.helidon.nima.webserver.http.HttpRules; import io.helidon.nima.webserver.http.ServerRequest; import io.helidon.nima.webserver.http.ServerResponse; @@ -44,13 +51,16 @@ abstract class StaticContentHandler implements StaticContentSupport { private static final System.Logger LOGGER = System.getLogger(StaticContentHandler.class.getName()); + private final Map inMemoryCache = new ConcurrentHashMap<>(); + private final LruCache handlerCache; private final String welcomeFilename; private final Function resolvePathFunction; - private int webServerCounter = 0; + private final AtomicInteger webServerCounter = new AtomicInteger(); StaticContentHandler(StaticContentSupport.Builder builder) { this.welcomeFilename = builder.welcomeFileName(); this.resolvePathFunction = builder.resolvePathFunction(); + this.handlerCache = builder.handlerCache(); } /** @@ -100,23 +110,16 @@ static void processEtag(String etag, ServerRequestHeaders requestHeaders, Server } } - /** - * Validates {@code If-Modify-Since} and {@code If-Unmodify-Since} headers and react accordingly. - * Returns {@code true} only if response was sent. - * - * @param modified the last modification instance. If {@code null} then method just returns {@code false}. - * @param requestHeaders an HTTP request headers - * @param responseHeaders an HTTP response headers - * @throws io.helidon.common.http.RequestException if (un)modify since header is checked - */ static void processModifyHeaders(Instant modified, ServerRequestHeaders requestHeaders, - ServerResponseHeaders responseHeaders) { + ServerResponseHeaders responseHeaders, + BiConsumer setModified) { if (modified == null) { return; } + // Last-Modified - responseHeaders.lastModified(modified); + setModified.accept(responseHeaders, modified); // If-Modified-Since Optional ifModSince = requestHeaders .ifModifiedSince() @@ -133,6 +136,21 @@ static void processModifyHeaders(Instant modified, } } + /** + * Validates {@code If-Modify-Since} and {@code If-Unmodify-Since} headers and react accordingly. + * Returns {@code true} only if response was sent. + * + * @param modified the last modification instance. If {@code null} then method just returns {@code false}. + * @param requestHeaders an HTTP request headers + * @param responseHeaders an HTTP response headers + * @throws io.helidon.common.http.RequestException if (un)modify since header is checked + */ + static void processModifyHeaders(Instant modified, + ServerRequestHeaders requestHeaders, + ServerResponseHeaders responseHeaders) { + processModifyHeaders(modified, requestHeaders, responseHeaders, ServerResponseHeaders::lastModified); + } + /** * If provided {@code condition} is {@code true} then throws not found {@link io.helidon.common.http.RequestException}. * @@ -145,37 +163,17 @@ static void throwNotFoundIf(boolean condition) { } } - /** - * Redirects to the given location. - * - * @param request request used to obtain query parameters for redirect - * @param response a server response to use - * @param location a location to redirect - */ - static void redirect(ServerRequest request, ServerResponse response, String location) { - UriQuery query = request.query(); - String locationWithQuery; - if (query.isEmpty()) { - locationWithQuery = location; - } else { - locationWithQuery = location + "?" + query.rawValue(); - } - - response.status(Http.Status.MOVED_PERMANENTLY_301); - response.header(Header.LOCATION, locationWithQuery); - response.send(); - } - @Override - public synchronized void beforeStart() { - webServerCounter++; + public void beforeStart() { + webServerCounter.incrementAndGet(); } @Override public void afterStop() { - webServerCounter--; - if (webServerCounter <= 0) { - webServerCounter = 0; + int i = webServerCounter.decrementAndGet(); + + if (i <= 0) { + webServerCounter.set(0); releaseCache(); } } @@ -191,6 +189,8 @@ public void routing(HttpRules rules) { * Should release cache (if any exists). */ void releaseCache() { + handlerCache.clear(); + inMemoryCache.clear(); } /** @@ -245,6 +245,51 @@ String welcomePageName() { return welcomeFilename; } + /** + * Cache in memory. + * Only use when explicitly requested by a user, we NEVER clear the cache during runtime. If you cache too much, + * you run out of memory. + * + * @param resource resource identifier (such as relative path), MUST be normalized and MUST exist to prevent caching + * records based on user's requests (that could cause us to cache the same resource multiple time using + * relative paths) + * @param handler in memory handler + */ + void cacheInMemory(String resource, CachedHandlerInMemory handler) { + inMemoryCache.put(resource, handler); + } + + /** + * Get in memory handler (if one is registered). + * + * @param resource resource to find + * @return handler if found + */ + Optional cacheInMemory(String resource) { + return Optional.ofNullable(inMemoryCache.get(resource)); + } + + /** + * Find either in-memory cache or cached record. + * + * @param resource resource to locate cache record for + * @return cached handler + */ + + Optional cacheHandler(String resource) { + return cacheInMemory(resource) + .map(CachedHandler.class::cast) + .or(() -> handlerCache.get(resource)); + } + + void cacheHandler(String resource, CachedHandler cachedResource) { + handlerCache.put(resource, cachedResource); + } + + LruCache handlerCache() { + return handlerCache; + } + private static String unquoteETag(String etag) { if (etag == null || etag.isEmpty()) { return etag; @@ -257,4 +302,39 @@ private static String unquoteETag(String etag) { } return etag; } + + void cacheInMemory(String resource, MediaType contentType, byte[] bytes, Optional lastModified) { + int contentLength = bytes.length; + Http.HeaderValue contentLengthHeader = Http.Header.create(Http.Header.CONTENT_LENGTH, contentLength); + + CachedHandlerInMemory inMemoryResource; + if (lastModified.isEmpty()) { + inMemoryResource = new CachedHandlerInMemory(contentType, + null, + null, + bytes, + contentLength, + contentLengthHeader); + } else { + // we can cache this, as this is a jar record + Http.HeaderValue lastModifiedHeader = Http.Header.create(Http.Header.LAST_MODIFIED, + true, + false, + formatLastModified(lastModified.get())); + + inMemoryResource = new CachedHandlerInMemory(contentType, + lastModified.get(), + (headers, instant) -> headers.set(lastModifiedHeader), + bytes, + contentLength, + contentLengthHeader); + } + + cacheInMemory(resource, inMemoryResource); + } + + static String formatLastModified(Instant lastModified) { + ZonedDateTime dt = ZonedDateTime.ofInstant(lastModified, ZoneId.systemDefault()); + return dt.format(Http.DateTime.RFC_1123_DATE_TIME); + } } diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentSupport.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentSupport.java index ccb25b470ee..ef60ba614b8 100644 --- a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentSupport.java +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/StaticContentSupport.java @@ -18,11 +18,14 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.TreeMap; import java.util.function.Function; +import io.helidon.common.configurable.LruCache; import io.helidon.common.media.type.MediaType; import io.helidon.nima.webserver.http.HttpService; @@ -131,6 +134,9 @@ static StaticContentSupport create(Path root) { abstract class Builder> implements io.helidon.common.Builder { private String welcomeFileName; private Function resolvePathFunction = Function.identity(); + private Set cacheInMemory = new HashSet<>(); + private LruCache handlerCache; + /** * Default constructor. @@ -170,6 +176,43 @@ public B pathMapper(Function resolvePathFunction) { return identity(); } + /** + * Add a path (as requested by the user) that should be cached in memory. + * The resource will be loaded into memory (as bytes) on server startup and served from memory, instead of + * accessing the resource each time. + * For classpath, each file must be explicitly specified (as we do not scan classpath), for file based + * this can also include directories. + * Helidon does not validate amount of memory used, be careful to have enough heap memory to cache the configured + * files. + *

+ * Files cached in memory will never be re-loaded, even if changed, until server restart! + *

+ * For classpath resource served from {@code web/index.html}, the {@code path} should be configured to + * {@code index.html} when the classpath root is set to {@code web}. + * + * @param path path to cache in memory + * @return updated builder + */ + public B addCacheInMemory(String path) { + this.cacheInMemory.add(path); + return identity(); + } + + /** + * Configure capacity of cache used for resources. This cache will make sure the media type and location is discovered + * faster. + * + * @param capacity maximal number of cached records, only caches media type and Path, not the content + * @return updated builder + */ + public B recordCacheCapacity(int capacity) { + this.handlerCache = LruCache.builder() + .capacity(capacity) + .build(); + return identity(); + } + + /** * Build the actual instance. * @@ -184,6 +227,15 @@ String welcomeFileName() { Function resolvePathFunction() { return resolvePathFunction; } + + Set cacheInMemory() { + return cacheInMemory; + } + + + LruCache handlerCache() { + return handlerCache == null ? LruCache.create() : handlerCache; + } } /** @@ -194,7 +246,6 @@ Function resolvePathFunction() { @SuppressWarnings("unchecked") abstract class FileBasedBuilder> extends Builder> { private final Map specificContentTypes = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - /** * Default constructor. */ diff --git a/nima/webserver/static-content/src/main/java/module-info.java b/nima/webserver/static-content/src/main/java/module-info.java index 770f8bdee9b..af7adfa8895 100644 --- a/nima/webserver/static-content/src/main/java/module-info.java +++ b/nima/webserver/static-content/src/main/java/module-info.java @@ -21,6 +21,7 @@ requires java.logging; requires transitive io.helidon.nima.webserver; + requires transitive io.helidon.common.configurable; exports io.helidon.nima.webserver.staticcontent; } \ No newline at end of file diff --git a/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java b/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java new file mode 100644 index 00000000000..30c29033290 --- /dev/null +++ b/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.staticcontent; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Optional; + +import io.helidon.common.buffers.BufferData; +import io.helidon.common.http.Http; +import io.helidon.common.http.HttpPrologue; +import io.helidon.common.http.ServerRequestHeaders; +import io.helidon.common.http.ServerResponseHeaders; +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.common.uri.UriQuery; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static io.helidon.common.testing.http.junit5.HttpHeaderMatcher.hasHeader; +import static io.helidon.common.testing.junit5.OptionalMatcher.optionalPresent; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CachedHandlerTest { + private static final MediaType MEDIA_TYPE_ICON = MediaTypes.create("image/x-icon"); + private static final Http.HeaderValue ICON_TYPE = Http.Header.create(Http.Header.CONTENT_TYPE, MEDIA_TYPE_ICON.text()); + private static final Http.HeaderValue RESOURCE_CONTENT_LENGTH = Http.Header.create(Http.Header.CONTENT_LENGTH, 7); + + private static ClassPathContentHandler handler; + + @BeforeAll + static void initTestClass() { + handler = (ClassPathContentHandler) StaticContentSupport.builder("/web") + .addCacheInMemory("favicon.ico") + .welcomeFileName("resource.txt") + .build(); + handler.beforeStart(); + } + + @Test + void testInMemoryCache() { + Optional cachedHandlerInMemory = handler.cacheInMemory("web/favicon.ico"); + assertThat("Handler should be cached in memory", cachedHandlerInMemory, optionalPresent()); + CachedHandlerInMemory cached = cachedHandlerInMemory.get(); + assertThat("Cached bytes must not be null", cached.bytes(), notNullValue()); + assertThat("Cached bytes must not be empty", cached.bytes(), not(BufferData.EMPTY_BYTES)); + assertThat("Content length", cached.contentLength(), is(1230)); + assertThat("Last modified", cached.lastModified(), notNullValue()); + assertThat("Media type", cached.mediaType(), is(MEDIA_TYPE_ICON)); + } + + @Test + void testFromInMemory() throws IOException, URISyntaxException { + ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); + + ServerRequest req = mock(ServerRequest.class); + when(req.headers()).thenReturn(ServerRequestHeaders.create()); + when(req.prologue()).thenReturn(HttpPrologue.create("http", "1.1", Http.Method.GET, "/favicon.ico", false)); + + ServerResponse res = mock(ServerResponse.class); + when(res.headers()).thenReturn(responseHeaders); + + boolean result = handler.doHandle(Http.Method.GET, "favicon.ico", req, res); + + assertThat("Handler should have found favicon.ico", result, is(true)); + assertThat(responseHeaders, hasHeader(ICON_TYPE)); + assertThat(responseHeaders, hasHeader(Http.Header.ETAG)); + assertThat(responseHeaders, hasHeader(Http.Header.LAST_MODIFIED)); + } + + @Test + void testCacheFound() throws IOException, URISyntaxException { + ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); + + ServerRequest req = mock(ServerRequest.class); + when(req.headers()).thenReturn(ServerRequestHeaders.create()); + when(req.prologue()).thenReturn(HttpPrologue.create("http", "1.1", Http.Method.GET, "/resource.txt", false)); + + ServerResponse res = mock(ServerResponse.class); + when(res.headers()).thenReturn(responseHeaders); + when(res.outputStream()).thenReturn(new ByteArrayOutputStream()); + + boolean result = handler.doHandle(Http.Method.GET, "resource.txt", req, res); + + assertThat("Handler should have found resource.txt", result, is(true)); + assertThat(responseHeaders, hasHeader(Http.HeaderValues.CONTENT_TYPE_TEXT_PLAIN)); + assertThat(responseHeaders, hasHeader(RESOURCE_CONTENT_LENGTH)); + assertThat(responseHeaders, hasHeader(Http.Header.ETAG)); + assertThat(responseHeaders, hasHeader(Http.Header.LAST_MODIFIED)); + + // now make sure it is cached + Optional cachedHandler = handler.cacheHandler("web/resource.txt"); + assertThat("Handler should be cached", cachedHandler, optionalPresent()); + CachedHandler cached = cachedHandler.get(); + assertThat("During tests, classpath should be loaded from file system", cached, instanceOf(CachedHandlerPath.class)); + CachedHandlerPath pathHandler = (CachedHandlerPath) cached; + assertThat("Path", pathHandler.path(), notNullValue()); + assertThat("Last modified function", pathHandler.lastModified(), notNullValue()); + assertThat("Last modified", pathHandler.lastModified().apply(pathHandler.path()), notNullValue()); + assertThat("Media type", pathHandler.mediaType(), is(MediaTypes.TEXT_PLAIN)); + } + + @Test + void testCacheRedirectFound() throws IOException, URISyntaxException { + ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); + + ServerRequest req = mock(ServerRequest.class); + when(req.headers()).thenReturn(ServerRequestHeaders.create()); + when(req.prologue()).thenReturn(HttpPrologue.create("http", "1.1", Http.Method.GET, "/nested", false)); + when(req.query()).thenReturn(UriQuery.empty()); + + ServerResponse res = mock(ServerResponse.class); + when(res.headers()).thenReturn(responseHeaders); + when(res.outputStream()).thenReturn(new ByteArrayOutputStream()); + + boolean result = handler.doHandle(Http.Method.GET, "/nested", req, res); + + assertThat("Handler should have redirected", result, is(true)); + assertThat(responseHeaders, hasHeader(Http.Header.LOCATION, "/nested/")); + + // now make sure it is cached + Optional cachedHandler = handler.cacheHandler("web/nested"); + assertThat("Handler should be cached", cachedHandler, optionalPresent()); + CachedHandler cached = cachedHandler.get(); + assertThat("This should be a cached redirect handler", cached, instanceOf(CachedHandlerRedirect.class)); + CachedHandlerRedirect redirectHandler = (CachedHandlerRedirect) cached; + assertThat(redirectHandler.location(), is("/nested/")); + } +} diff --git a/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/StaticContentHandlerTest.java b/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/StaticContentHandlerTest.java index 0a57feff0e7..549c64d161f 100644 --- a/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/StaticContentHandlerTest.java +++ b/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/StaticContentHandlerTest.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; +import io.helidon.common.configurable.LruCache; import io.helidon.common.http.Http; import io.helidon.common.http.Http.Header; import io.helidon.common.http.HttpException; @@ -32,6 +33,7 @@ import io.helidon.common.http.ServerRequestHeaders; import io.helidon.common.http.ServerResponseHeaders; import io.helidon.common.parameters.Parameters; +import io.helidon.common.testing.http.junit5.HttpHeaderMatcher; import io.helidon.common.uri.UriFragment; import io.helidon.common.uri.UriPath; import io.helidon.common.uri.UriQuery; @@ -145,15 +147,17 @@ void ifUnmodifySince_NotAccept() { } @Test - void redirect() { + void redirect() throws IOException { ServerResponseHeaders resh = mock(ServerResponseHeaders.class); ServerResponse res = mock(ServerResponse.class); ServerRequest req = mock(ServerRequest.class); when(res.headers()).thenReturn(resh); when(req.query()).thenReturn(UriQuery.empty()); - StaticContentHandler.redirect(req, res, "/foo/"); + + CachedHandlerRedirect redirectHandler = new CachedHandlerRedirect("/foo/"); + redirectHandler.handle(LruCache.create(), Http.Method.GET, req, res, "/foo"); verify(res).status(Http.Status.MOVED_PERMANENTLY_301); - verify(res).header(LOCATION, "/foo/"); + verify(resh).set(LOCATION, "/foo/"); verify(res).send(); } @@ -173,6 +177,7 @@ void handleValid() { ServerResponse response = mock(ServerResponse.class); TestContentHandler handler = TestContentHandler.create(true); handler.handle(request, response); + // the file is valid, but it does not exist verify(response, never()).next(); assertThat(handler.path, is(Paths.get("foo/some.txt").toAbsolutePath().normalize())); } @@ -283,7 +288,13 @@ static TestContentHandler create(boolean returnValue) { } @Override - boolean doHandle(Http.Method method, Path path, ServerRequest request, ServerResponse response) { + boolean doHandle(Http.Method method, + String requestedResource, + ServerRequest req, + ServerResponse res, + String rawPath, + Path path) { + this.counter.incrementAndGet(); this.path = path; return returnValue; diff --git a/nima/webserver/static-content/src/test/resources/web/nested/resource.txt b/nima/webserver/static-content/src/test/resources/web/nested/resource.txt new file mode 100644 index 00000000000..24a90d89d47 --- /dev/null +++ b/nima/webserver/static-content/src/test/resources/web/nested/resource.txt @@ -0,0 +1 @@ +Content \ No newline at end of file diff --git a/nima/webserver/static-content/src/test/resources/web/resource.txt b/nima/webserver/static-content/src/test/resources/web/resource.txt new file mode 100644 index 00000000000..24a90d89d47 --- /dev/null +++ b/nima/webserver/static-content/src/test/resources/web/resource.txt @@ -0,0 +1 @@ +Content \ No newline at end of file diff --git a/tests/integration/mp-gh-4654/src/test/java/io/helidon/tests/integration/gh4654/Gh4654StaticContentTest.java b/tests/integration/mp-gh-4654/src/test/java/io/helidon/tests/integration/gh4654/Gh4654StaticContentTest.java index 626f0da8381..71877ebc3c2 100644 --- a/tests/integration/mp-gh-4654/src/test/java/io/helidon/tests/integration/gh4654/Gh4654StaticContentTest.java +++ b/tests/integration/mp-gh-4654/src/test/java/io/helidon/tests/integration/gh4654/Gh4654StaticContentTest.java @@ -100,9 +100,9 @@ static void cleanup() { "/index.html,Root Index HTML,path should serve index.html", "/foo.txt,Foo TXT,path should serve foo.txt", "/css/a.css,A CSS,path should serve css/a.css", - "/other,Other Index,path should serve other/index.html", + "/other/,Other Index,path should serve other/index.html", "/other/index.html,Other Index,path should serve other/index.html", - "/classpath,classpath index,classpath should serve index.html", + "/classpath/,classpath index,classpath should serve index.html", "/classpath/index.html,classpath index,classpath should serve index.html" }) void testExists(String path, String expectedContent, String name) { From d54ff3ca468800835ddfe748adc4b6b7ffa2b231 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Mon, 28 Nov 2022 21:03:10 +0100 Subject: [PATCH 2/3] Add support for directory in file system static content handler --- .../FileSystemContentHandler.java | 27 +++- .../staticcontent/CachedHandlerTest.java | 124 ++++++++++++++++-- 2 files changed, 134 insertions(+), 17 deletions(-) diff --git a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java index baf2c98628b..d1ab42bb424 100644 --- a/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java +++ b/nima/webserver/static-content/src/main/java/io/helidon/nima/webserver/staticcontent/FileSystemContentHandler.java @@ -44,7 +44,7 @@ class FileSystemContentHandler extends FileBasedContentHandler { FileSystemContentHandler(StaticContentSupport.FileSystemBuilder builder) { super(builder); - this.root = builder.root(); + this.root = builder.root().toAbsolutePath().normalize(); this.cacheInMemory = new HashSet<>(builder.cacheInMemory()); } @@ -77,7 +77,7 @@ boolean doHandle(Http.Method method, String requestedPath, ServerRequest req, Se return false; } - String rawPath = req.path().rawPath(); + String rawPath = req.prologue().uriPath().rawPath(); String relativePath = root.relativize(path).toString(); String requestedResource = rawPath.endsWith("/") ? relativePath + "/" : relativePath; @@ -168,8 +168,27 @@ private void addToInMemoryCache(String resource) throws IOException, URISyntaxEx return; } - byte[] fileBytes = Files.readAllBytes(path); + if (Files.isDirectory(path)) { + try (var paths = Files.newDirectoryStream(path)) { + paths.forEach(child -> { + if (!Files.isDirectory(child)) { + // we need to use forward slash even on Windows + String childResource = root.relativize(child).toString().replace('\\', '/'); + try { + addToInMemoryCache(childResource, child); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "File " + child + " cannot be added to in memory cache", e); + } + } + }); + } + } else { + addToInMemoryCache(resource, path); + } + } + private void addToInMemoryCache(String resource, Path path) throws IOException { + byte[] fileBytes = Files.readAllBytes(path); cacheInMemory(resource, detectType(fileName(path)), fileBytes, lastModified(path)); } @@ -177,6 +196,6 @@ private Path requestedPath(String requestedPath) { if (requestedPath.isEmpty()) { return root; } - return root.resolve(requestedPath).normalize(); + return root.resolve(requestedPath).toAbsolutePath().normalize(); } } diff --git a/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java b/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java index 30c29033290..e0d2c2278be 100644 --- a/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java +++ b/nima/webserver/static-content/src/test/java/io/helidon/nima/webserver/staticcontent/CachedHandlerTest.java @@ -19,6 +19,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URISyntaxException; +import java.nio.file.Paths; import java.util.Optional; import io.helidon.common.buffers.BufferData; @@ -50,20 +51,27 @@ class CachedHandlerTest { private static final Http.HeaderValue ICON_TYPE = Http.Header.create(Http.Header.CONTENT_TYPE, MEDIA_TYPE_ICON.text()); private static final Http.HeaderValue RESOURCE_CONTENT_LENGTH = Http.Header.create(Http.Header.CONTENT_LENGTH, 7); - private static ClassPathContentHandler handler; + private static ClassPathContentHandler classpathHandler; + private static FileSystemContentHandler fsHandler; @BeforeAll static void initTestClass() { - handler = (ClassPathContentHandler) StaticContentSupport.builder("/web") + classpathHandler = (ClassPathContentHandler) StaticContentSupport.builder("/web") .addCacheInMemory("favicon.ico") .welcomeFileName("resource.txt") .build(); - handler.beforeStart(); + classpathHandler.beforeStart(); + + fsHandler = (FileSystemContentHandler) StaticContentSupport.builder(Paths.get("./src/test/resources/web")) + .addCacheInMemory("nested") + .welcomeFileName("resource.txt") + .build(); + fsHandler.beforeStart(); } @Test - void testInMemoryCache() { - Optional cachedHandlerInMemory = handler.cacheInMemory("web/favicon.ico"); + void testClasspathInMemoryCache() { + Optional cachedHandlerInMemory = classpathHandler.cacheInMemory("web/favicon.ico"); assertThat("Handler should be cached in memory", cachedHandlerInMemory, optionalPresent()); CachedHandlerInMemory cached = cachedHandlerInMemory.get(); assertThat("Cached bytes must not be null", cached.bytes(), notNullValue()); @@ -74,7 +82,7 @@ void testInMemoryCache() { } @Test - void testFromInMemory() throws IOException, URISyntaxException { + void testClasspathFromInMemory() throws IOException, URISyntaxException { ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); ServerRequest req = mock(ServerRequest.class); @@ -84,7 +92,7 @@ void testFromInMemory() throws IOException, URISyntaxException { ServerResponse res = mock(ServerResponse.class); when(res.headers()).thenReturn(responseHeaders); - boolean result = handler.doHandle(Http.Method.GET, "favicon.ico", req, res); + boolean result = classpathHandler.doHandle(Http.Method.GET, "favicon.ico", req, res); assertThat("Handler should have found favicon.ico", result, is(true)); assertThat(responseHeaders, hasHeader(ICON_TYPE)); @@ -93,7 +101,7 @@ void testFromInMemory() throws IOException, URISyntaxException { } @Test - void testCacheFound() throws IOException, URISyntaxException { + void testClasspathCacheFound() throws IOException, URISyntaxException { ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); ServerRequest req = mock(ServerRequest.class); @@ -104,7 +112,7 @@ void testCacheFound() throws IOException, URISyntaxException { when(res.headers()).thenReturn(responseHeaders); when(res.outputStream()).thenReturn(new ByteArrayOutputStream()); - boolean result = handler.doHandle(Http.Method.GET, "resource.txt", req, res); + boolean result = classpathHandler.doHandle(Http.Method.GET, "resource.txt", req, res); assertThat("Handler should have found resource.txt", result, is(true)); assertThat(responseHeaders, hasHeader(Http.HeaderValues.CONTENT_TYPE_TEXT_PLAIN)); @@ -113,7 +121,7 @@ void testCacheFound() throws IOException, URISyntaxException { assertThat(responseHeaders, hasHeader(Http.Header.LAST_MODIFIED)); // now make sure it is cached - Optional cachedHandler = handler.cacheHandler("web/resource.txt"); + Optional cachedHandler = classpathHandler.cacheHandler("web/resource.txt"); assertThat("Handler should be cached", cachedHandler, optionalPresent()); CachedHandler cached = cachedHandler.get(); assertThat("During tests, classpath should be loaded from file system", cached, instanceOf(CachedHandlerPath.class)); @@ -125,7 +133,97 @@ void testCacheFound() throws IOException, URISyntaxException { } @Test - void testCacheRedirectFound() throws IOException, URISyntaxException { + void testClasspathCacheRedirectFound() throws IOException, URISyntaxException { + ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); + + ServerRequest req = mock(ServerRequest.class); + when(req.headers()).thenReturn(ServerRequestHeaders.create()); + when(req.prologue()).thenReturn(HttpPrologue.create("http", "1.1", Http.Method.GET, "/nested", false)); + when(req.query()).thenReturn(UriQuery.empty()); + + ServerResponse res = mock(ServerResponse.class); + when(res.headers()).thenReturn(responseHeaders); + when(res.outputStream()).thenReturn(new ByteArrayOutputStream()); + + boolean result = classpathHandler.doHandle(Http.Method.GET, "/nested", req, res); + + assertThat("Handler should have redirected", result, is(true)); + assertThat(responseHeaders, hasHeader(Http.Header.LOCATION, "/nested/")); + + // now make sure it is cached + Optional cachedHandler = classpathHandler.cacheHandler("web/nested"); + assertThat("Handler should be cached", cachedHandler, optionalPresent()); + CachedHandler cached = cachedHandler.get(); + assertThat("This should be a cached redirect handler", cached, instanceOf(CachedHandlerRedirect.class)); + CachedHandlerRedirect redirectHandler = (CachedHandlerRedirect) cached; + assertThat(redirectHandler.location(), is("/nested/")); + } + + @Test + void testFsInMemoryCache() { + Optional cachedHandlerInMemory = fsHandler.cacheInMemory("nested/resource.txt"); + assertThat("Handler should be cached in memory", cachedHandlerInMemory, optionalPresent()); + CachedHandlerInMemory cached = cachedHandlerInMemory.get(); + assertThat("Cached bytes must not be null", cached.bytes(), notNullValue()); + assertThat("Cached bytes must not be empty", cached.bytes(), not(BufferData.EMPTY_BYTES)); + assertThat("Content length", cached.contentLength(), is(7)); + assertThat("Last modified", cached.lastModified(), notNullValue()); + assertThat("Media type", cached.mediaType(), is(MediaTypes.TEXT_PLAIN)); + } + + @Test + void testFsFromInMemory() throws IOException { + ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); + + ServerRequest req = mock(ServerRequest.class); + when(req.headers()).thenReturn(ServerRequestHeaders.create()); + when(req.prologue()).thenReturn(HttpPrologue.create("http", "1.1", Http.Method.GET, "nested/resource.txt", false)); + + ServerResponse res = mock(ServerResponse.class); + when(res.headers()).thenReturn(responseHeaders); + + boolean result = fsHandler.doHandle(Http.Method.GET, "nested/resource.txt", req, res); + + assertThat("Handler should have found nested/resource.txt", result, is(true)); + assertThat(responseHeaders, hasHeader(Http.HeaderValues.CONTENT_TYPE_TEXT_PLAIN)); + assertThat(responseHeaders, hasHeader(Http.Header.ETAG)); + assertThat(responseHeaders, hasHeader(Http.Header.LAST_MODIFIED)); + } + + @Test + void testFsCacheFound() throws IOException { + ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); + + ServerRequest req = mock(ServerRequest.class); + when(req.headers()).thenReturn(ServerRequestHeaders.create()); + when(req.prologue()).thenReturn(HttpPrologue.create("http", "1.1", Http.Method.GET, "/resource.txt", false)); + + ServerResponse res = mock(ServerResponse.class); + when(res.headers()).thenReturn(responseHeaders); + when(res.outputStream()).thenReturn(new ByteArrayOutputStream()); + + boolean result = fsHandler.doHandle(Http.Method.GET, "resource.txt", req, res); + + assertThat("Handler should have found resource.txt", result, is(true)); + assertThat(responseHeaders, hasHeader(Http.HeaderValues.CONTENT_TYPE_TEXT_PLAIN)); + assertThat(responseHeaders, hasHeader(RESOURCE_CONTENT_LENGTH)); + assertThat(responseHeaders, hasHeader(Http.Header.ETAG)); + assertThat(responseHeaders, hasHeader(Http.Header.LAST_MODIFIED)); + + // now make sure it is cached + Optional cachedHandler = fsHandler.cacheHandler("resource.txt"); + assertThat("Handler should be cached", cachedHandler, optionalPresent()); + CachedHandler cached = cachedHandler.get(); + assertThat("During tests, fs should be loaded from file system", cached, instanceOf(CachedHandlerPath.class)); + CachedHandlerPath pathHandler = (CachedHandlerPath) cached; + assertThat("Path", pathHandler.path(), notNullValue()); + assertThat("Last modified function", pathHandler.lastModified(), notNullValue()); + assertThat("Last modified", pathHandler.lastModified().apply(pathHandler.path()), notNullValue()); + assertThat("Media type", pathHandler.mediaType(), is(MediaTypes.TEXT_PLAIN)); + } + + @Test + void testFsCacheRedirectFound() throws IOException { ServerResponseHeaders responseHeaders = ServerResponseHeaders.create(); ServerRequest req = mock(ServerRequest.class); @@ -137,13 +235,13 @@ void testCacheRedirectFound() throws IOException, URISyntaxException { when(res.headers()).thenReturn(responseHeaders); when(res.outputStream()).thenReturn(new ByteArrayOutputStream()); - boolean result = handler.doHandle(Http.Method.GET, "/nested", req, res); + boolean result = fsHandler.doHandle(Http.Method.GET, "nested", req, res); assertThat("Handler should have redirected", result, is(true)); assertThat(responseHeaders, hasHeader(Http.Header.LOCATION, "/nested/")); // now make sure it is cached - Optional cachedHandler = handler.cacheHandler("web/nested"); + Optional cachedHandler = fsHandler.cacheHandler("nested"); assertThat("Handler should be cached", cachedHandler, optionalPresent()); CachedHandler cached = cachedHandler.get(); assertThat("This should be a cached redirect handler", cached, instanceOf(CachedHandlerRedirect.class)); From 4abf583092510044769202b23894ab5a73262a61 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Mon, 28 Nov 2022 23:21:10 +0100 Subject: [PATCH 3/3] Spotbugs fix - URL handling only from classpath --- nima/webserver/static-content/etc/spotbugs/exclude.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nima/webserver/static-content/etc/spotbugs/exclude.xml b/nima/webserver/static-content/etc/spotbugs/exclude.xml index 72748c8776f..570e8f9134c 100644 --- a/nima/webserver/static-content/etc/spotbugs/exclude.xml +++ b/nima/webserver/static-content/etc/spotbugs/exclude.xml @@ -33,4 +33,10 @@ + + + + + +