diff --git a/bom/pom.xml b/bom/pom.xml index 387001a7663..59f6aa539e8 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1,7 +1,7 @@ + + + helidon-tests-integration-webserver + io.helidon.tests.integration + 2.2.1-SNAPSHOT + + 4.0.0 + + helidon-tests-integration-webserver-gh2631 + Helidon Integration Test WebServer GH 2631 + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.webserver + helidon-webserver-static-content + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + io.helidon.webclient + helidon-webclient + test + + + + \ No newline at end of file diff --git a/tests/integration/webserver/gh2631/src/main/java/io/helidon/tests/integration/webserver/gh2631/Gh2631.java b/tests/integration/webserver/gh2631/src/main/java/io/helidon/tests/integration/webserver/gh2631/Gh2631.java new file mode 100644 index 00000000000..a74dd9a33de --- /dev/null +++ b/tests/integration/webserver/gh2631/src/main/java/io/helidon/tests/integration/webserver/gh2631/Gh2631.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021 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.tests.integration.webserver.gh2631; + +import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; + +import io.helidon.webserver.Routing; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.staticcontent.StaticContentSupport; + +public class Gh2631 { + public static void main(String[] args) { + startServer(); + } + + static WebServer startServer() { + return WebServer.builder() + .routing(routing()) + .build() + .start() + .await(10, TimeUnit.SECONDS); + } + + private static Routing routing() { + StaticContentSupport classpath = StaticContentSupport.builder("web") + .welcomeFileName("index.txt") + .build(); + StaticContentSupport file = StaticContentSupport.builder(Paths.get("src/main/resources/web")) + .welcomeFileName("index.txt") + .build(); + + return Routing.builder() + .register("/simple", classpath) + .register("/fallback", classpath) + .register("/fallback", StaticContentSupport.builder("fallback") + .pathMapper(path -> "index.txt") + .build()) + .register("/simpleFile", file) + .register("/fallbackFile", file) + .register("/fallbackFile", StaticContentSupport.builder(Paths.get("src/main/resources/fallback")) + .pathMapper(path -> "index.txt") + .build()) + .build(); + } +} diff --git a/tests/integration/webserver/gh2631/src/main/resources/fallback/index.txt b/tests/integration/webserver/gh2631/src/main/resources/fallback/index.txt new file mode 100644 index 00000000000..7934b309d9c --- /dev/null +++ b/tests/integration/webserver/gh2631/src/main/resources/fallback/index.txt @@ -0,0 +1 @@ +fallback \ No newline at end of file diff --git a/tests/integration/webserver/gh2631/src/main/resources/web/first/index.txt b/tests/integration/webserver/gh2631/src/main/resources/web/first/index.txt new file mode 100644 index 00000000000..fe4f02ad058 --- /dev/null +++ b/tests/integration/webserver/gh2631/src/main/resources/web/first/index.txt @@ -0,0 +1 @@ +first \ No newline at end of file diff --git a/tests/integration/webserver/gh2631/src/main/resources/web/index.txt b/tests/integration/webserver/gh2631/src/main/resources/web/index.txt new file mode 100644 index 00000000000..93ca1422a8d --- /dev/null +++ b/tests/integration/webserver/gh2631/src/main/resources/web/index.txt @@ -0,0 +1 @@ +root \ No newline at end of file diff --git a/tests/integration/webserver/gh2631/src/test/java/io/helidon/tests/integration/webserver/gh2631/Gh2631Test.java b/tests/integration/webserver/gh2631/src/test/java/io/helidon/tests/integration/webserver/gh2631/Gh2631Test.java new file mode 100644 index 00000000000..ef18b600f28 --- /dev/null +++ b/tests/integration/webserver/gh2631/src/test/java/io/helidon/tests/integration/webserver/gh2631/Gh2631Test.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2021 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.tests.integration.webserver.gh2631; + +import java.util.concurrent.TimeUnit; + +import io.helidon.common.http.Http; +import io.helidon.webclient.WebClient; +import io.helidon.webclient.WebClientResponse; +import io.helidon.webserver.WebServer; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +class Gh2631Test { + private static WebClient client; + private static WebServer server; + + @BeforeAll + static void beforeAll() { + server = Gh2631.startServer(); + client = WebClient.builder() + .baseUri("http://localhost:" + server.port()) + .followRedirects(true) + .build(); + } + + @AfterAll + static void afterAll() { + if (server != null) { + server.shutdown() + .await(10, TimeUnit.SECONDS); + } + } + + @Test + void testClasspathNoFallback() { + String value = get("/simple/first/"); + assertThat(value, is("first")); + + value = get("/simple/first/index.txt"); + assertThat(value, is("first")); + } + + @Test + void testClasspathNoFallbackMissing() { + WebClientResponse response = getResponse("/simple/second/"); + assertThat(response.status(), is(Http.Status.NOT_FOUND_404)); + } + + @Test + void testClasspathFallback() { + String value = get("/fallback/first/"); + assertThat(value, is("first")); + + value = get("/fallback/first/index.txt"); + assertThat(value, is("first")); + } + + @Test + void testClasspathFallbackMissing() { + String value = get("/fallback/second/"); + assertThat(value, is("fallback")); + + value = get("/fallback/second/any/path/anyFile.txt"); + assertThat(value, is("fallback")); + } + + @Test + void testFileNoFallback() { + String value = get("/simpleFile/first/"); + assertThat(value, is("first")); + + value = get("/simpleFile/first/index.txt"); + assertThat(value, is("first")); + } + + @Test + void testFileNoFallbackMissing() { + WebClientResponse response = getResponse("/simpleFile/second/"); + assertThat(response.status(), is(Http.Status.NOT_FOUND_404)); + } + + @Test + void testFileFallback() { + String value = get("/fallbackFile/first/"); + assertThat(value, is("first")); + + value = get("/fallbackFile/first/index.txt"); + assertThat(value, is("first")); + } + + @Test + void testFileFallbackMissing() { + String value = get("/fallbackFile/second/"); + assertThat(value, is("fallback")); + + value = get("/fallbackFile/second/any/path/anyFile.txt"); + assertThat(value, is("fallback")); + } + + private WebClientResponse getResponse(String path) { + return client.get() + .path(path) + .request() + .await(10, TimeUnit.SECONDS); + } + + private String get(String path) { + return client.get() + .path(path) + .request(String.class) + .await(10, TimeUnit.SECONDS); + } +} \ No newline at end of file diff --git a/tests/integration/webserver/pom.xml b/tests/integration/webserver/pom.xml new file mode 100644 index 00000000000..62bd18ade01 --- /dev/null +++ b/tests/integration/webserver/pom.xml @@ -0,0 +1,40 @@ + + + + + 4.0.0 + + + io.helidon.tests.integration + helidon-tests-integration + 2.2.1-SNAPSHOT + + + pom + helidon-tests-integration-webserver + Helidon Integration Tests WebServer + + + WebServer integration tests + + + + gh2631 + + \ No newline at end of file diff --git a/webserver/pom.xml b/webserver/pom.xml index 5208dc14b53..eb1f803f08d 100644 --- a/webserver/pom.xml +++ b/webserver/pom.xml @@ -1,7 +1,7 @@ + + + + helidon-webserver-project + io.helidon.webserver + 2.2.1-SNAPSHOT + + 4.0.0 + + helidon-webserver-static-content + Helidon WebServer Static Content + + + Static content support for Helidon WebServer + + + + + io.helidon.common + helidon-common-http + + + io.helidon.common + helidon-common-reactive + + + io.helidon.common + helidon-common-media-type + + + io.helidon.media + helidon-media-common + + + io.helidon.webserver + helidon-webserver + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + org.mockito + mockito-core + test + + + diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java new file mode 100644 index 00000000000..859880f044a --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2017, 2021 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.webserver.staticcontent; + +import java.io.IOException; +import java.io.InputStream; +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.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.logging.Logger; + +import io.helidon.common.http.DataChunk; +import io.helidon.common.http.Http; +import io.helidon.common.reactive.IoMulti; +import io.helidon.webserver.HttpException; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; + +/** + * Handles static content from the classpath. + */ +class ClassPathContentHandler extends FileBasedContentHandler { + private static final Logger LOGGER = Logger.getLogger(ClassPathContentHandler.class.getName()); + + private final ClassLoader classLoader; + private final String root; + private final String rootWithTrailingSlash; + private final BiFunction tmpFile; + + // URL's hash code and equal are not suitable for map or set + private final Map extracted = new ConcurrentHashMap<>(); + + ClassPathContentHandler(StaticContentSupport.ClassPathBuilder builder) { + super(builder); + + this.classLoader = builder.classLoader(); + this.root = builder.root(); + this.rootWithTrailingSlash = root + '/'; + + Path tmpDir = builder.tmpDir(); + if (tmpDir == null) { + this.tmpFile = (prefix, suffix) -> { + try { + return Files.createTempFile(prefix, suffix); + } catch (IOException e) { + throw new HttpException("Static content processing issue", Http.Status.INTERNAL_SERVER_ERROR_500, e); + } + }; + } else { + this.tmpFile = (prefix, suffix) -> { + try { + return Files.createTempFile(tmpDir, prefix, suffix); + } catch (IOException e) { + throw new HttpException("Static content processing issue", Http.Status.INTERNAL_SERVER_ERROR_500, e); + } + }; + } + } + + @SuppressWarnings("checkstyle:RegexpSinglelineJava") + @Override + boolean doHandle(Http.RequestMethod method, String requestedPath, ServerRequest request, ServerResponse response) + throws IOException, URISyntaxException { + + String resource = requestedPath.isEmpty() ? root : (rootWithTrailingSlash + requestedPath); + + LOGGER.finest(() -> "Requested class path resource: " + resource); + + // 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(); + + if (!requestedResource.equals(root) && !requestedResource.startsWith(rootWithTrailingSlash)) { + return false; + } + + // 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); + + String welcomeFileName = welcomePageName(); + if (null != welcomeFileName) { + String welcomeFileResource = requestedResource + "/" + welcomeFileName; + URL welcomeUrl = classLoader.getResource(welcomeFileResource); + if (null != welcomeUrl) { + // there is a welcome file under requested resource, ergo requested resource was a directory + String rawFullPath = request.uri().getRawPath(); + if (rawFullPath.endsWith("/")) { + // this is OK, as the path ends with a forward slash + url = welcomeUrl; + } else { + // must redirect + redirect(request, response, rawFullPath + "/"); + return true; + } + } + } + + if (url == null) { + LOGGER.fine(() -> "Requested resource " + resource + " does not exist"); + return false; + } + + URL logUrl = url; // need to be effectively final to use in lambda + LOGGER.finest(() -> "Located resource url. Resource: " + resource + ", URL: " + logUrl); + + // 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; + } + + return true; + } + + boolean sendJar(Http.RequestMethod method, + String requestedResource, + URL url, + ServerRequest request, + ServerResponse response) { + + LOGGER.fine(() -> "Sending static content from classpath: " + url); + + 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()); + } + + String entryName = (extrEntry.entryName == null) ? fileName(url) : extrEntry.entryName; + + processContentType(entryName, + request.headers(), + response.headers()); + + if (method == Http.Method.HEAD) { + response.send(); + } else { + send(response, extrEntry.tempFile); + } + + return true; + } + + private ExtractedJarEntry existOrCreate(URL url, ExtractedJarEntry entry) { + if (entry == null) { + return extractJarEntry(url); + } + if (entry.tempFile == null) { + return entry; + } + if (Files.notExists(entry.tempFile)) { + return extractJarEntry(url); + } + return entry; + } + + private void sendUrlStream(Http.RequestMethod method, URL url, ServerRequest request, ServerResponse response) + throws IOException { + + LOGGER.finest(() -> "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; + } + + InputStream in = url.openStream(); + response.send(IoMulti.multiFromStreamBuilder(in) + .byteBufferSize(2048) + .build() + .map(DataChunk::create)); + } + + static String fileName(URL url) { + String path = url.getPath(); + int index = path.lastIndexOf('/'); + if (index > -1) { + return path.substring(index + 1); + } + + return path; + } + + private ExtractedJarEntry extractJarEntry(URL url) { + try { + JarURLConnection jarUrlConnection = (JarURLConnection) url.openConnection(); + JarFile jarFile = jarUrlConnection.getJarFile(); + JarEntry jarEntry = jarUrlConnection.getJarEntry(); + if (jarEntry.isDirectory()) { + return new ExtractedJarEntry(jarEntry.getName()); // a directory + } + Instant lastModified = getLastModified(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()); + } finally { + if (!jarUrlConnection.getUseCaches()) { + jarFile.close(); + } + } + } catch (IOException ioe) { + throw new HttpException("Cannot load JAR file!", Http.Status.INTERNAL_SERVER_ERROR_500, ioe); + } + } + + private Instant getLastModified(String path) throws IOException { + Path file = Paths.get(path); + + if (Files.exists(file) && Files.isRegularFile(file)) { + return Files.getLastModifiedTime(file).toInstant(); + } else { + return null; + } + } + + private static class ExtractedJarEntry { + private final Path tempFile; + private final Instant lastModified; + private final String entryName; + + ExtractedJarEntry(Path tempFile, Instant lastModified, String entryName) { + this.tempFile = tempFile; + this.lastModified = lastModified; + this.entryName = entryName; + } + + /** + * Creates directory representation. + */ + ExtractedJarEntry(String entryName) { + this.tempFile = null; + this.lastModified = null; + this.entryName = entryName; + } + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java new file mode 100644 index 00000000000..25409171eb8 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2021 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.webserver.staticcontent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Logger; + +import io.helidon.common.http.Http; +import io.helidon.common.http.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.media.common.DefaultMediaSupport; +import io.helidon.media.common.MessageBodyWriter; +import io.helidon.webserver.HttpException; +import io.helidon.webserver.RequestHeaders; +import io.helidon.webserver.ResponseHeaders; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; + +abstract class FileBasedContentHandler extends StaticContentHandler { + private static final Logger LOGGER = Logger.getLogger(FileBasedContentHandler.class.getName()); + private static final MessageBodyWriter PATH_WRITER = DefaultMediaSupport.pathWriter(); + + private final Map customMediaTypes; + + FileBasedContentHandler(StaticContentSupport.FileBasedBuilder builder) { + super(builder); + + this.customMediaTypes = builder.specificContentTypes(); + } + + static String fileName(Path path) { + Path fileName = path.getFileName(); + + if (null == fileName) { + return ""; + } + + return fileName.toString(); + } + + /** + * Find welcome file in provided directory or throw not found {@link io.helidon.webserver.HttpException}. + * + * @param directory a directory to find in + * @param name welcome file name + * @return a path of the welcome file + * @throws io.helidon.webserver.HttpException if welcome file doesn't exists + */ + private static Path resolveWelcomeFile(Path directory, String name) { + throwNotFoundIf(name == null || name.isEmpty()); + Path result = directory.resolve(name); + throwNotFoundIf(!Files.exists(result)); + return result; + } + + /** + * 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, + RequestHeaders requestHeaders, + ResponseHeaders responseHeaders) { + // Try to get Content-Type + responseHeaders.contentType(detectType(filename, requestHeaders)); + } + + private MediaType detectType(String fileName, RequestHeaders 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(MediaType::parse)) + .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); + }) + .orElseGet(() -> { + List acceptedTypes = requestHeaders.acceptedTypes(); + if (acceptedTypes.isEmpty()) { + return MediaType.APPLICATION_OCTET_STREAM; + } else { + return acceptedTypes.iterator().next(); + } + }); + } + + 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.RequestMethod method, + Path pathParam, + ServerRequest request, + ServerResponse response, + String welcomePage) + throws IOException { + + LOGGER.fine(() -> "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.uri().getRawPath(); + 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 HttpException("File is not accessible", Http.Status.FORBIDDEN_403); + } + + // 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) { + response.send(); + } else { + send(response, path); + } + } + + void send(ServerResponse response, Path path) { + response.send(PATH_WRITER.marshall(path)); + } + + +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java new file mode 100644 index 00000000000..8b79da9963c --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2017, 2021 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.webserver.staticcontent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +import io.helidon.common.http.Http; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; + +/** + * Serves files from the filesystem as a static WEB content. + */ +class FileSystemContentHandler extends FileBasedContentHandler { + private static final Logger LOGGER = Logger.getLogger(FileSystemContentHandler.class.getName()); + + private final Path root; + + FileSystemContentHandler(StaticContentSupport.FileSystemBuilder builder) { + super(builder); + + this.root = builder.root(); + } + + @Override + boolean doHandle(Http.RequestMethod method, String requestedPath, ServerRequest request, ServerResponse response) + throws IOException { + Path resolved; + if (requestedPath.isEmpty()) { + resolved = root; + } else { + resolved = root.resolve(requestedPath).normalize(); + LOGGER.finest(() -> "Requested file: " + resolved.toAbsolutePath()); + if (!resolved.startsWith(root)) { + return false; + } + } + + return doHandle(method, resolved, request, response); + } + + boolean doHandle(Http.RequestMethod method, Path path, ServerRequest request, ServerResponse response) throws IOException { + // Check existence + if (!Files.exists(path)) { + return false; + } + + sendFile(method, path, request, response, welcomePageName()); + + return true; + } + +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java new file mode 100644 index 00000000000..ab45b5f88b7 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2017, 2021 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.webserver.staticcontent; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.time.Instant; +import java.time.chrono.ChronoZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; + +import io.helidon.common.http.Http; +import io.helidon.webserver.HttpException; +import io.helidon.webserver.RequestHeaders; +import io.helidon.webserver.ResponseHeaders; +import io.helidon.webserver.Routing; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; + +/** + * Base implementation of static content support. + */ +abstract class StaticContentHandler implements StaticContentSupport { + private static final Logger LOGGER = Logger.getLogger(StaticContentHandler.class.getName()); + + private final String welcomeFilename; + private final Function resolvePathFunction; + + StaticContentHandler(StaticContentSupport.Builder builder) { + this.welcomeFilename = builder.welcomeFileName(); + this.resolvePathFunction = builder.resolvePathFunction(); + } + + private int webServerCounter = 0; + + @Override + public void update(Routing.Rules routing) { + routing.onNewWebServer(ws -> { + webServerStarted(); + ws.whenShutdown().thenRun(this::webServerStopped); + }); + routing.get((req, res) -> handle(Http.Method.GET, req, res)); + routing.head((req, res) -> handle(Http.Method.HEAD, req, res)); + } + + private synchronized void webServerStarted() { + webServerCounter++; + } + + private synchronized void webServerStopped() { + webServerCounter--; + if (webServerCounter <= 0) { + webServerCounter = 0; + releaseCache(); + } + } + + /** + * Should release cache (if any exists). + */ + void releaseCache() { + } + + /** + * Do handle for GET and HEAD HTTP methods. It is filtering implementation, prefers {@code response.next()} before NOT_FOUND. + * + * @param method an HTTP method + * @param request an HTTP request + * @param response an HTTP response + */ + void handle(Http.RequestMethod method, ServerRequest request, ServerResponse response) { + // Check method + if ((method != Http.Method.GET) && (method != Http.Method.HEAD)) { + request.next(); + return; + } + // Resolve path + String requestPath = request.path().toString(); + if (requestPath.startsWith("/")) { + requestPath = requestPath.substring(1); + } + requestPath = resolvePathFunction.apply(requestPath); + + // Call doHandle + try { + if (!doHandle(method, requestPath, request, response)) { + request.next(); + } + } catch (HttpException httpException) { + if (httpException.status().code() == Http.Status.NOT_FOUND_404.code()) { + // Prefer to next() before NOT_FOUND + request.next(); + } else { + throw httpException; + } + } catch (Exception e) { + LOGGER.log(Level.FINE, "Failed to access static resource", e); + throw new HttpException("Cannot access static resource!", Http.Status.INTERNAL_SERVER_ERROR_500, e); + } + } + + /** + * Do handle for GET and HEAD HTTP methods. + * + * @param method GET or HEAD HTTP method + * @param requestedPath path to the requested resource + * @param request an HTTP request + * @param response an HTTP response + * @return {@code true} only if static content was found and processed. + * @throws java.io.IOException if resource is not acceptable + * @throws io.helidon.webserver.HttpException if some known WEB error + */ + abstract boolean doHandle(Http.RequestMethod method, String requestedPath, ServerRequest request, ServerResponse response) + throws IOException, URISyntaxException; + + /** + * Put {@code etag} parameter (if provided ) into the response headers, than validates {@code If-Match} and + * {@code If-None-Match} headers and react accordingly. + * + * @param etag the proposed ETag. If {@code null} then method returns false + * @param requestHeaders an HTTP request headers + * @param responseHeaders an HTTP response headers + * @throws io.helidon.webserver.HttpException if ETag is checked + */ + static void processEtag(String etag, RequestHeaders requestHeaders, ResponseHeaders responseHeaders) { + if (etag == null || etag.isEmpty()) { + return; + } + etag = unquoteETag(etag); + // Put ETag into the response + responseHeaders.put(Http.Header.ETAG, '"' + etag + '"'); + // Process If-None-Match header + List ifNoneMatches = requestHeaders.values(Http.Header.IF_NONE_MATCH); + for (String ifNoneMatch : ifNoneMatches) { + ifNoneMatch = unquoteETag(ifNoneMatch); + if ("*".equals(ifNoneMatch) || ifNoneMatch.equals(etag)) { + throw new HttpException("Accepted by If-None-Match header!", Http.Status.NOT_MODIFIED_304); + } + } + // Process If-Match header + List ifMatches = requestHeaders.values(Http.Header.IF_MATCH); + if (!ifMatches.isEmpty()) { + boolean ifMatchChecked = false; + for (String ifMatch : ifMatches) { + ifMatch = unquoteETag(ifMatch); + if ("*".equals(ifMatch) || ifMatch.equals(etag)) { + ifMatchChecked = true; + break; + } + } + if (!ifMatchChecked) { + throw new HttpException("Not accepted by If-Match header!", Http.Status.PRECONDITION_FAILED_412); + } + } + } + + private static String unquoteETag(String etag) { + if (etag == null || etag.isEmpty()) { + return etag; + } + if (etag.startsWith("W/") || etag.startsWith("w/")) { + etag = etag.substring(2); + } + if (etag.startsWith("\"") && etag.endsWith("\"")) { + etag = etag.substring(1, etag.length() - 1); + } + return etag; + } + + /** + * 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.webserver.HttpException if (un)modify since header is checked + */ + static void processModifyHeaders(Instant modified, RequestHeaders requestHeaders, ResponseHeaders responseHeaders) { + if (modified == null) { + return; + } + // Last-Modified + responseHeaders.lastModified(modified); + // If-Modified-Since + Optional ifModSince = requestHeaders + .ifModifiedSince() + .map(ChronoZonedDateTime::toInstant); + if (ifModSince.isPresent() && !ifModSince.get().isBefore(modified)) { + throw new HttpException("Not valid for If-Modified-Since header!", Http.Status.NOT_MODIFIED_304); + } + // If-Unmodified-Since + Optional ifUnmodSince = requestHeaders + .ifUnmodifiedSince() + .map(ChronoZonedDateTime::toInstant); + if (ifUnmodSince.isPresent() && ifUnmodSince.get().isBefore(modified)) { + throw new HttpException("Not valid for If-Unmodified-Since header!", Http.Status.PRECONDITION_FAILED_412); + } + } + + /** + * If provided {@code condition} is {@code true} then throws not found {@link io.helidon.webserver.HttpException}. + * + * @param condition if condition is true then throws an exception otherwise not + * @throws io.helidon.webserver.HttpException if {@code condition} parameter is {@code true}. + */ + static void throwNotFoundIf(boolean condition) { + if (condition) { + throw new HttpException("Content not found!", Http.Status.NOT_FOUND_404); + } + } + + /** + * 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) { + String query = request.query(); + String locationWithQuery; + if (query == null) { + locationWithQuery = location; + } else { + locationWithQuery = location + "?" + query; + } + + response.status(Http.Status.MOVED_PERMANENTLY_301); + response.headers().put(Http.Header.LOCATION, locationWithQuery); + response.send(); + } + + String welcomePageName() { + return welcomeFilename; + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentSupport.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentSupport.java new file mode 100644 index 00000000000..3d22f3099a2 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentSupport.java @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2017, 2021 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.webserver.staticcontent; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.function.Function; + +import io.helidon.common.http.MediaType; +import io.helidon.webserver.Service; + +/** + * Serves 'static content' (files) from filesystem or using a classloader to the {@link io.helidon.webserver.WebServer WebServer} + * {@link io.helidon.webserver.Routing}. It is possible to + * {@link io.helidon.webserver.Routing.Builder#register(io.helidon.webserver.Service...) register} it on the routing. + *
{@code
+ * // Serve content of attached '/static/pictures' on '/pics'
+ * Routing.builder()
+ *        .register("/pics", StaticContentSupport.create("/static/pictures"))
+ *        .build()
+ * }
+ *

+ * Content is served ONLY on HTTP {@code GET} method. + */ +public interface StaticContentSupport extends Service { + /** + * Creates new builder with defined static content root as a class-loader resource. Builder provides ability to define + * more advanced configuration. + *

+ * Current context classloader is used to load static content. + * + * @param resourceRoot a root resource path. + * @return a builder + * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + */ + static ClassPathBuilder builder(String resourceRoot) { + Objects.requireNonNull(resourceRoot, "Attribute resourceRoot is null!"); + return builder(resourceRoot, Thread.currentThread().getContextClassLoader()); + } + + /** + * Creates new builder with defined static content root as a class-loader resource. Builder provides ability to define + * more advanced configuration. + * + * @param resourceRoot a root resource path. + * @param classLoader a class-loader for the static content + * @return a builder + * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + */ + static ClassPathBuilder builder(String resourceRoot, ClassLoader classLoader) { + Objects.requireNonNull(resourceRoot, "Attribute resourceRoot is null!"); + return new ClassPathBuilder() + .root(resourceRoot) + .classLoader(classLoader); + } + + /** + * Creates new builder with defined static content root as a path to the file system. Builder provides ability to define + * more advanced configuration. + * + * @param root a root path. + * @return a builder + * @throws NullPointerException if {@code root} attribute is {@code null} + */ + static FileSystemBuilder builder(Path root) { + Objects.requireNonNull(root, "Attribute root is null!"); + return new FileSystemBuilder() + .root(root); + } + + /** + * Creates new instance with defined static content root as a class-loader resource. + *

+ * Current context classloader is used to load static content. + * + * @param resourceRoot a root resource path. + * @return created instance + * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + */ + static StaticContentSupport create(String resourceRoot) { + return create(resourceRoot, Thread.currentThread().getContextClassLoader()); + } + + /** + * Creates new instance with defined static content root as a class-loader resource. + * + * @param resourceRoot a root resource path. + * @param classLoader a class-loader for the static content + * @return created instance + * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + */ + static StaticContentSupport create(String resourceRoot, ClassLoader classLoader) { + return builder(resourceRoot, classLoader).build(); + } + + /** + * Creates new instance with defined static content root as a path to the file system. + * + * @param root a root path. + * @return created instance + * @throws NullPointerException if {@code root} attribute is {@code null} + */ + static StaticContentSupport create(Path root) { + return builder(root).build(); + } + + /** + * Fluent builder of the StaticContent detailed parameters. + * @param type of a subclass of a concrete builder + */ + @SuppressWarnings("unchecked") + abstract class Builder> implements io.helidon.common.Builder { + private String welcomeFileName; + private Function resolvePathFunction = Function.identity(); + + protected Builder() { + } + + @Override + public final StaticContentSupport build() { + return doBuild(); + } + + /** + * Build the actual instance. + * + * @return static content support + */ + protected abstract StaticContentSupport doBuild(); + + /** + * Sets a name of the "file" which will be returned if directory is requested. + * + * @param welcomeFileName a name of the welcome file + * @return updated builder + */ + public B welcomeFileName(String welcomeFileName) { + Objects.requireNonNull(welcomeFileName, "Welcome file cannot be null"); + if (welcomeFileName.isBlank()) { + throw new IllegalArgumentException("Welcome file cannot be empty"); + } + this.welcomeFileName = welcomeFileName; + return (B) this; + } + + /** + * Map request path to resource path. Default uses the same path as requested. + * This can be used to resolve all paths to a single file, or to filter out files. + * + * @param resolvePathFunction function + * @return updated builder + */ + public B pathMapper(Function resolvePathFunction) { + this.resolvePathFunction = resolvePathFunction; + return (B) this; + } + + String welcomeFileName() { + return welcomeFileName; + } + + Function resolvePathFunction() { + return resolvePathFunction; + } + } + + /** + * Builder for file based static content supports, such as file based and classpath based. + * @param type of a subclass of a concrete builder + */ + @SuppressWarnings("unchecked") + abstract class FileBasedBuilder> extends StaticContentSupport.Builder> { + private final Map specificContentTypes = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + /** + * Maps a filename extension to the response content type. + * To have a system wide configuration, you can use the service loader SPI + * {@link io.helidon.common.media.type.spi.MediaTypeDetector}. + * + * This method can override {@link io.helidon.common.media.type.MediaTypes} detection + * for static content handling only. + * + * @param filenameExtension a filename extension. The part after the last {code dot '.'} in the name. + * @param contentType a mapped content type + * @return updated builder + * @throws NullPointerException if any parameter is {@code null} + * @throws IllegalArgumentException if {@code filenameExtension} is empty + */ + public T contentType(String filenameExtension, MediaType contentType) { + Objects.requireNonNull(filenameExtension, "Parameter 'filenameExtension' is null!"); + Objects.requireNonNull(contentType, "Parameter 'contentType' is null!"); + filenameExtension = filenameExtension.trim(); + if (filenameExtension.startsWith(".")) { + filenameExtension = filenameExtension.substring(1); + } + if (filenameExtension.isEmpty()) { + throw new IllegalArgumentException("Parameter 'filenameExtension' cannot be empty!"); + } + specificContentTypes.put(filenameExtension, contentType); + return (T) this; + } + + Map specificContentTypes() { + return specificContentTypes; + } + } + + /** + * Builder for class path based static content. + */ + class ClassPathBuilder extends StaticContentSupport.FileBasedBuilder { + private String clRoot; + private ClassLoader classLoader; + private Path tmpDir; + + protected ClassPathBuilder() { + } + + @Override + protected StaticContentSupport doBuild() { + return new ClassPathContentHandler(this); + } + + ClassPathBuilder classLoader(ClassLoader cl) { + this.classLoader = cl; + return this; + } + + ClassPathBuilder root(String root) { + Objects.requireNonNull(root, "Attribute root is null!"); + String cleanRoot = root; + if (cleanRoot.startsWith("/")) { + cleanRoot = cleanRoot.substring(1); + } + while (cleanRoot.endsWith("/")) { + cleanRoot = cleanRoot.substring(0, cleanRoot.length() - 1); + } + + if (cleanRoot.isEmpty()) { + throw new IllegalArgumentException("Cannot serve full classpath, please configure a classpath prefix"); + } + + this.clRoot = cleanRoot; + + return this; + } + + /** + * Sets custom temporary folder for extracting static content from a jar. + * + * @param tmpDir custom temporary folder + * @return updated builder + */ + public ClassPathBuilder tmpDir(Path tmpDir) { + this.tmpDir = tmpDir; + return this; + } + + String root() { + return clRoot; + } + + ClassLoader classLoader() { + return classLoader; + } + + Path tmpDir() { + return tmpDir; + } + } + + /** + * Builder for file system based static content. + */ + class FileSystemBuilder extends StaticContentSupport.FileBasedBuilder { + private Path root; + + protected FileSystemBuilder() { + } + + @Override + protected StaticContentSupport doBuild() { + return new FileSystemContentHandler(this); + } + + FileSystemBuilder root(Path root) { + Objects.requireNonNull(root, "Attribute root is null!"); + this.root = root.toAbsolutePath().normalize(); + + if (!(Files.exists(this.root) && Files.isDirectory(this.root))) { + throw new IllegalArgumentException("Cannot create file system static content, path " + + this.root + + " does not exist or is not a directory"); + } + return this; + } + + Path root() { + return root; + } + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/package-info.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/package-info.java new file mode 100644 index 00000000000..a5438fcca28 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2021 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. + */ + +/** + * Static content support for Helidon {@link io.helidon.webserver.WebServer}. + *

+ * The starting point is {@link io.helidon.webserver.staticcontent.StaticContentSupport}. + */ +package io.helidon.webserver.staticcontent; diff --git a/webserver/static-content/src/main/java/module-info.java b/webserver/static-content/src/main/java/module-info.java new file mode 100644 index 00000000000..8568856b9f9 --- /dev/null +++ b/webserver/static-content/src/main/java/module-info.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021 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. + */ + +/** + * Static content support for Helidon WebServer. + * Supports both classpath and file system based static content. + */ +module io.helidon.webserver.staticcontent { + requires java.logging; + + requires io.helidon.common.http; + requires io.helidon.common.media.type; + requires io.helidon.common.reactive; + requires io.helidon.media.common; + requires io.helidon.webserver; + + exports io.helidon.webserver.staticcontent; +} \ No newline at end of file diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java new file mode 100644 index 00000000000..d6e89892195 --- /dev/null +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2018, 2021 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.webserver.staticcontent; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import io.helidon.common.http.Http; +import io.helidon.webserver.HttpException; +import io.helidon.webserver.RequestHeaders; +import io.helidon.webserver.ResponseHeaders; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests {@link StaticContentHandler}. + */ +class StaticContentHandlerTest { + + private static void assertHttpException(Runnable runnable, Http.Status status) { + try { + runnable.run(); + throw new AssertionError("Expected HttpException was not thrown!"); + } catch (HttpException he) { + if (status != null && status.code() != he.status().code()) { + throw new AssertionError("Unexpected status in HttpException. " + + "(Expected: " + status.code() + ", Actual: " + status.code() + ")"); + } + } + } + + @Test + void etag_InNonMatch_NotAccept() { + RequestHeaders req = mock(RequestHeaders.class); + when(req.values(Http.Header.IF_NONE_MATCH)).thenReturn(List.of("\"ccc\"", "\"ddd\"")); + when(req.values(Http.Header.IF_MATCH)).thenReturn(Collections.emptyList()); + ResponseHeaders res = mock(ResponseHeaders.class); + StaticContentHandler.processEtag("aaa", req, res); + verify(res).put(Http.Header.ETAG, "\"aaa\""); + } + + @Test + void etag_InNonMatch_Accept() { + RequestHeaders req = mock(RequestHeaders.class); + when(req.values(Http.Header.IF_NONE_MATCH)).thenReturn(List.of("\"ccc\"", "W/\"aaa\"")); + when(req.values(Http.Header.IF_MATCH)).thenReturn(Collections.emptyList()); + ResponseHeaders res = mock(ResponseHeaders.class); + assertHttpException(() -> StaticContentHandler.processEtag("aaa", req, res), Http.Status.NOT_MODIFIED_304); + verify(res).put(Http.Header.ETAG, "\"aaa\""); + } + + @Test + void etag_InMatch_NotAccept() { + RequestHeaders req = mock(RequestHeaders.class); + when(req.values(Http.Header.IF_MATCH)).thenReturn(List.of("\"ccc\"", "\"ddd\"")); + when(req.values(Http.Header.IF_NONE_MATCH)).thenReturn(Collections.emptyList()); + ResponseHeaders res = mock(ResponseHeaders.class); + assertHttpException(() -> StaticContentHandler.processEtag("aaa", req, res), Http.Status.PRECONDITION_FAILED_412); + verify(res).put(Http.Header.ETAG, "\"aaa\""); + } + + @Test + void etag_InMatch_Accept() { + RequestHeaders req = mock(RequestHeaders.class); + when(req.values(Http.Header.IF_MATCH)).thenReturn(List.of("\"ccc\"", "\"aaa\"")); + when(req.values(Http.Header.IF_NONE_MATCH)).thenReturn(Collections.emptyList()); + ResponseHeaders res = mock(ResponseHeaders.class); + StaticContentHandler.processEtag("aaa", req, res); + verify(res).put(Http.Header.ETAG, "\"aaa\""); + } + + @Test + void ifModifySince_Accept() { + ZonedDateTime modified = ZonedDateTime.now(); + RequestHeaders req = mock(RequestHeaders.class); + Mockito.doReturn(Optional.of(modified.minusSeconds(60))).when(req).ifModifiedSince(); + Mockito.doReturn(Optional.empty()).when(req).ifUnmodifiedSince(); + ResponseHeaders res = mock(ResponseHeaders.class); + StaticContentHandler.processModifyHeaders(modified.toInstant(), req, res); + } + + @Test + void ifModifySince_NotAccept() { + ZonedDateTime modified = ZonedDateTime.now(); + RequestHeaders req = mock(RequestHeaders.class); + Mockito.doReturn(Optional.of(modified)).when(req).ifModifiedSince(); + Mockito.doReturn(Optional.empty()).when(req).ifUnmodifiedSince(); + ResponseHeaders res = mock(ResponseHeaders.class); + assertHttpException(() -> StaticContentHandler.processModifyHeaders(modified.toInstant(), req, res), + Http.Status.NOT_MODIFIED_304); + } + + @Test + void ifUnmodifySince_Accept() { + ZonedDateTime modified = ZonedDateTime.now(); + RequestHeaders req = mock(RequestHeaders.class); + Mockito.doReturn(Optional.of(modified)).when(req).ifUnmodifiedSince(); + Mockito.doReturn(Optional.empty()).when(req).ifModifiedSince(); + ResponseHeaders res = mock(ResponseHeaders.class); + StaticContentHandler.processModifyHeaders(modified.toInstant(), req, res); + } + + @Test + void ifUnmodifySince_NotAccept() { + ZonedDateTime modified = ZonedDateTime.now(); + RequestHeaders req = mock(RequestHeaders.class); + Mockito.doReturn(Optional.of(modified.minusSeconds(60))).when(req).ifUnmodifiedSince(); + Mockito.doReturn(Optional.empty()).when(req).ifModifiedSince(); + ResponseHeaders res = mock(ResponseHeaders.class); + assertHttpException(() -> StaticContentHandler.processModifyHeaders(modified.toInstant(), req, res), + Http.Status.PRECONDITION_FAILED_412); + } + + @Test + void redirect() { + ResponseHeaders resh = mock(ResponseHeaders.class); + ServerResponse res = mock(ServerResponse.class); + ServerRequest req = mock(ServerRequest.class); + Mockito.doReturn(resh).when(res).headers(); + StaticContentHandler.redirect(req, res, "/foo/"); + verify(res).status(Http.Status.MOVED_PERMANENTLY_301); + verify(resh).put(Http.Header.LOCATION, "/foo/"); + verify(res).send(); + } + + private ServerRequest mockRequestWithPath(String path) { + ServerRequest.Path p = mock(ServerRequest.Path.class); + Mockito.doReturn(path).when(p).toString(); + ServerRequest request = mock(ServerRequest.class); + Mockito.doReturn(p).when(request).path(); + return request; + } + + @Test + void handleRoot() { + ServerRequest request = mockRequestWithPath("/"); + ServerResponse response = mock(ServerResponse.class); + TestContentHandler handler = TestContentHandler.create(true); + handler.handle(Http.Method.GET, request, response); + verify(request, never()).next(); + assertThat(handler.path, is(Paths.get(".").toAbsolutePath().normalize())); + } + + @Test + void handleIllegalMethod() { + ServerRequest request = mockRequestWithPath("/"); + ServerResponse response = mock(ServerResponse.class); + TestContentHandler handler = TestContentHandler.create(true); + handler.handle(Http.Method.POST, request, response); + verify(request).next(); + assertThat(handler.counter.get(), is(0)); + } + + @Test + void handleValid() { + ServerRequest request = mockRequestWithPath("/foo/some.txt"); + ServerResponse response = mock(ServerResponse.class); + TestContentHandler handler = TestContentHandler.create(true); + handler.handle(Http.Method.GET, request, response); + verify(request, never()).next(); + assertThat(handler.path, is(Paths.get("foo/some.txt").toAbsolutePath().normalize())); + } + + @Test + void handleOutside() { + ServerRequest request = mockRequestWithPath("/../foo/some.txt"); + ServerResponse response = mock(ServerResponse.class); + TestContentHandler handler = TestContentHandler.create(true); + handler.handle(Http.Method.GET, request, response); + verify(request).next(); + assertThat(handler.counter.get(), is(0)); + } + + @Test + void handleNextOnFalse() { + ServerRequest request = mockRequestWithPath("/"); + ServerResponse response = mock(ServerResponse.class); + TestContentHandler handler = TestContentHandler.create(false); + handler.handle(Http.Method.GET, request, response); + verify(request).next(); + assertThat(handler.counter.get(), is(1)); + } + + @Test + void classpathHandleSpaces() { + ServerRequest request = mockRequestWithPath("foo/I have spaces.txt"); + ServerResponse response = mock(ServerResponse.class); + TestClassPathContentHandler handler = TestClassPathContentHandler.create(); + handler.handle(Http.Method.GET, request, response); + verify(request, never()).next(); + assertThat(handler.counter.get(), is(1)); + } + + + static class TestContentHandler extends FileSystemContentHandler { + + final AtomicInteger counter = new AtomicInteger(0); + final boolean returnValue; + Path path; + + TestContentHandler(StaticContentSupport.FileSystemBuilder builder, boolean returnValue) { + super(builder); + this.returnValue = returnValue; + } + + static TestContentHandler create(boolean returnValue) { + return new TestContentHandler(StaticContentSupport.builder(Paths.get(".")), returnValue); + } + + @Override + boolean doHandle(Http.RequestMethod method, Path path, ServerRequest request, ServerResponse response) { + this.counter.incrementAndGet(); + this.path = path; + return returnValue; + } + } + + static class TestClassPathContentHandler extends ClassPathContentHandler { + + final AtomicInteger counter = new AtomicInteger(0); + final boolean returnValue; + + TestClassPathContentHandler(StaticContentSupport.ClassPathBuilder builder, boolean returnValue) { + super(builder); + this.returnValue = returnValue; + } + + static TestClassPathContentHandler create() { + return new TestClassPathContentHandler(StaticContentSupport.builder("/root"), true); + } + + @Override + boolean doHandle(Http.RequestMethod method, String path, ServerRequest request, ServerResponse response) + throws IOException, URISyntaxException { + super.doHandle(method, path, request, response); + this.counter.incrementAndGet(); + return returnValue; + } + + } +} diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ClassPathContentHandler.java b/webserver/webserver/src/main/java/io/helidon/webserver/ClassPathContentHandler.java index 7397d70741e..e3dc76aad03 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ClassPathContentHandler.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ClassPathContentHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2020 Oracle and/or its affiliates. + * Copyright (c) 2017, 2021 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. @@ -41,6 +41,7 @@ /** * Handles static content from the classpath. */ +@Deprecated class ClassPathContentHandler extends StaticContentHandler { private static final Logger LOGGER = Logger.getLogger(ClassPathContentHandler.class.getName()); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ContentTypeSelector.java b/webserver/webserver/src/main/java/io/helidon/webserver/ContentTypeSelector.java index a356a0abf5e..0708d3f86ed 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ContentTypeSelector.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ContentTypeSelector.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2018 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2017, 2021 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. @@ -26,6 +26,7 @@ /** * Provides mapping between filename extension and media type. */ +@Deprecated class ContentTypeSelector { private static final Map CONTENT_TYPES = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/FileSystemContentHandler.java b/webserver/webserver/src/main/java/io/helidon/webserver/FileSystemContentHandler.java index 2f7c88967b5..30d0103cd14 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/FileSystemContentHandler.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/FileSystemContentHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2020 Oracle and/or its affiliates. + * Copyright (c) 2017, 2021 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. @@ -30,6 +30,7 @@ /** * Serves files from the filesystem as a static WEB content. */ +@Deprecated class FileSystemContentHandler extends StaticContentHandler { private static final Logger LOGGER = Logger.getLogger(FileSystemContentHandler.class.getName()); private static final MessageBodyWriter PATH_WRITER = DefaultMediaSupport.pathWriter(); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentHandler.java b/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentHandler.java index de6fc07b86d..0c00d9bb34b 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentHandler.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2020 Oracle and/or its affiliates. + * Copyright (c) 2017, 2021 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. @@ -32,6 +32,7 @@ /** * Request {@link Handler} processing a static content. */ +@Deprecated abstract class StaticContentHandler { private static final Logger LOGGER = Logger.getLogger(StaticContentHandler.class.getName()); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentSupport.java b/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentSupport.java index 4f2b4ef9f0c..9037f8e0b21 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentSupport.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/StaticContentSupport.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2020 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2017, 2021 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. @@ -36,7 +36,10 @@ * } *

* Content is served ONLY on HTTP {@code GET} method. + * + * @deprecated please use module {@code helidon-webserver-static-content} */ +@Deprecated(since = "2.3.0", forRemoval = true) public class StaticContentSupport implements Service { private final StaticContentHandler handler;