diff --git a/.drone.yml b/.drone.yml index 251bdd0..e4c41bc 100644 --- a/.drone.yml +++ b/.drone.yml @@ -20,7 +20,9 @@ steps: from_secret: repo-token commands: - mvn -s maven-settings.xml deploy -DskipTests=true - when: + trigger: branch: - include: - - master + - master + event: + exclude: + - pull_request diff --git a/labyrinth-server.iml b/labyrinth-server.iml index 8d460f3..ee5c0f7 100644 --- a/labyrinth-server.iml +++ b/labyrinth-server.iml @@ -12,16 +12,17 @@ - - - + + + - - + + + diff --git a/pom.xml b/pom.xml index b18d2cf..9be4803 100644 --- a/pom.xml +++ b/pom.xml @@ -14,11 +14,16 @@ labyrinth-server 0.0.1-SNAPSHOT + + 1.2.10 + 1.7.35 + + ch.fritteli.labyrinth labyrinth-generator - 0.0.1 + 0.0.2 io.vavr @@ -35,12 +40,12 @@ org.slf4j slf4j-api - 1.7.30 + ${slf4j.version} - org.slf4j - slf4j-simple - 1.7.30 + ch.qos.logback + logback-classic + ${logback.version} org.junit.jupiter @@ -95,12 +100,24 @@ - repo.gittr.ch - https://repo.gittr.ch/ + repo.gittr.ch.releases + https://repo.gittr.ch/releases/ true never + + false + never + + + + repo.gittr.ch.snapshots + https://repo.gittr.ch/snapshots/ + + false + never + true always diff --git a/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java b/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java index bcb6bd4..a3a3790 100644 --- a/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java +++ b/src/main/java/ch/fritteli/labyrinth/server/LabyrinthServer.java @@ -4,10 +4,16 @@ import ch.fritteli.labyrinth.generator.model.Labyrinth; import ch.fritteli.labyrinth.generator.renderer.html.HTMLRenderer; import ch.fritteli.labyrinth.generator.renderer.pdf.PDFRenderer; import ch.fritteli.labyrinth.generator.renderer.text.TextRenderer; +import ch.fritteli.labyrinth.generator.serialization.SerializerDeserializer; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; -import io.vavr.collection.*; +import io.vavr.collection.HashMap; +import io.vavr.collection.HashSet; +import io.vavr.collection.List; +import io.vavr.collection.Map; +import io.vavr.collection.Set; +import io.vavr.collection.Stream; import io.vavr.control.Option; import io.vavr.control.Try; import lombok.Getter; @@ -18,17 +24,33 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; @Slf4j public class LabyrinthServer { + @NonNull private final HttpServer httpServer; + @NonNull + private final ExecutorService executorService = new ThreadPoolExecutor( + 0, + 1_000, + 5, + TimeUnit.SECONDS, + new SynchronousQueue<>() + ); - public LabyrinthServer(@NonNull final ServerConfig config) throws IOException { + public LabyrinthServer(@NonNull final ServerConfig config) throws IOException, URISyntaxException { this.httpServer = HttpServer.create(new InetSocketAddress(config.getAddress(), config.getPort()), 5); + this.httpServer.createContext("/", new StaticResourcesFileHandler(this.executorService)); this.httpServer.createContext("/create", this::handleCreate); + this.httpServer.createContext("/render", this::handleRender); } public static Option createAndStartServer() { @@ -83,52 +105,111 @@ public class LabyrinthServer { public void stop() { log.info("Stopping server ..."); this.httpServer.stop(5); + this.executorService.shutdown(); + try { + if (!this.executorService.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("Timeout occurred while awaiting termination of executor service"); + } + } catch (final InterruptedException e) { + log.error("Failed to await termination of executor service", e); + } log.info("Server stopped."); } private void handleCreate(HttpExchange exchange) throws IOException { - log.debug("Handling request to {}", exchange.getRequestURI()); - final String requestMethod = exchange.getRequestMethod(); - if (!requestMethod.equals("GET")) { - exchange.getResponseBody().close(); - exchange.sendResponseHeaders(405, -1); - exchange.close(); - return; - } - final Map requestParams = this.parseQueryString(exchange.getRequestURI().getQuery()); - final int width = this.getOrDefault(requestParams.get(RequestParameter.WIDTH), Integer::valueOf, 5); - final int height = this.getOrDefault(requestParams.get(RequestParameter.HEIGHT), Integer::valueOf, 5); - final Option idOption = requestParams.get(RequestParameter.ID).toTry().map(Long::valueOf).toOption(); - final Option outputOption = requestParams.get(RequestParameter.OUTPUT).flatMap(OutputType::ofString); - final Headers responseHeaders = exchange.getResponseHeaders(); - final AtomicBoolean needsRedirect = new AtomicBoolean(false); - final long id = idOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(System::nanoTime); - final OutputType output = outputOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(OutputType.HTML); - if (needsRedirect.get()) { - responseHeaders.add("Location", "/create?width=" + width + "&height=" + height + "&output=" + output.toString() + "&id=" + id); - exchange.sendResponseHeaders(302, -1); - exchange.close(); - return; - } - final Labyrinth labyrinth = new Labyrinth(width, height, id); - final byte[] render; - try { - render = output.render(labyrinth); - } catch (Exception e) { - responseHeaders.add("Content-type", "text/plain; charset=UTF-8"); - exchange.sendResponseHeaders(500, 0); - final OutputStream responseBody = exchange.getResponseBody(); - responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8)); - responseBody.flush(); - exchange.close(); - return; - } - responseHeaders.add("Content-type", output.getContentType()); - exchange.sendResponseHeaders(200, 0); - final OutputStream responseBody = exchange.getResponseBody(); - responseBody.write(render); - responseBody.flush(); - exchange.close(); + this.executorService.submit(() -> { + log.debug("Handling request to {}", exchange.getRequestURI()); + try { + final String requestMethod = exchange.getRequestMethod(); + if (!requestMethod.equals("GET")) { + exchange.getResponseBody().close(); + exchange.sendResponseHeaders(405, -1); + return; + } + final Map requestParams = this.parseQueryString(exchange.getRequestURI().getQuery()); + final int width = this.getOrDefault(requestParams.get(RequestParameter.WIDTH), Integer::valueOf, 5); + final int height = this.getOrDefault(requestParams.get(RequestParameter.HEIGHT), Integer::valueOf, 5); + final Option idOption = requestParams.get(RequestParameter.ID).toTry().map(Long::valueOf).toOption(); + final Option outputOption = requestParams.get(RequestParameter.OUTPUT).flatMap(OutputType::ofString); + final Headers responseHeaders = exchange.getResponseHeaders(); + final AtomicBoolean needsRedirect = new AtomicBoolean(false); + final long id = idOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(System::nanoTime); + final OutputType output = outputOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(OutputType.HTML); + if (needsRedirect.get()) { + responseHeaders.add("Location", "/create?width=" + width + "&height=" + height + "&output=" + output.toString() + "&id=" + id); + exchange.sendResponseHeaders(302, -1); + return; + } + final Labyrinth labyrinth = new Labyrinth(width, height, id); + final byte[] render; + try { + render = output.render(labyrinth); + } catch (Exception e) { + responseHeaders.add("Content-type", "text/plain; charset=UTF-8"); + exchange.sendResponseHeaders(500, 0); + final OutputStream responseBody = exchange.getResponseBody(); + responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8)); + responseBody.flush(); + return; + } + responseHeaders.add("Content-type", output.getContentType()); + if (output.equals(OutputType.BINARY)) { + responseHeaders.add("Content-disposition", "attachment; filename=\"labyrinth-" + width + "x" + height + "-" + id + ".laby\""); + } + exchange.sendResponseHeaders(200, 0); + final OutputStream responseBody = exchange.getResponseBody(); + responseBody.write(render); + responseBody.flush(); + } catch (Exception e) { + log.error("FSCK!", e); + } finally { + exchange.close(); + } + }); + } + + private void handleRender(final HttpExchange exchange) throws IOException { + this.executorService.submit(() -> { + try { + log.debug("Handling request to {}", exchange.getRequestURI()); + final String requestMethod = exchange.getRequestMethod(); + if (!requestMethod.equals("POST")) { + exchange.getResponseBody().close(); + exchange.sendResponseHeaders(405, -1); + return; + } + final byte[] bytes = exchange.getRequestBody().readAllBytes(); + + final Labyrinth labyrinth = SerializerDeserializer.deserialize(bytes); + + final OutputType output = exchange.getRequestHeaders() + .get("Accept") + .contains("text/html") ? + OutputType.HTML : + OutputType.TEXT_PLAIN; + final byte[] render; + final Headers responseHeaders = exchange.getResponseHeaders(); + try { + render = output.render(labyrinth); + } catch (Exception e) { + responseHeaders.add("Content-type", "text/plain; charset=UTF-8"); + exchange.sendResponseHeaders(500, 0); + final OutputStream responseBody = exchange.getResponseBody(); + responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8)); + responseBody.flush(); + return; + } + responseHeaders.add("Content-type", output.getContentType()); + exchange.sendResponseHeaders(200, 0); + final OutputStream responseBody = exchange.getResponseBody(); + responseBody.write(render); + responseBody.flush(); + } catch (Exception e) { + log.error("FSCK!", e); + } finally { + exchange.close(); + } + }); } private enum RequestParameter { @@ -154,7 +235,8 @@ public class LabyrinthServer { private enum OutputType { TEXT_PLAIN("text/plain; charset=UTF-8", labyrinth -> TextRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), "t", "text"), HTML("text/html", labyrinth -> HTMLRenderer.newInstance().render(labyrinth).getBytes(StandardCharsets.UTF_8), "h", "html"), - PDF("application/pdf", labyrinth -> PDFRenderer.newInstance().render(labyrinth), "p", "pdf"); + PDF("application/pdf", labyrinth -> PDFRenderer.newInstance().render(labyrinth), "p", "pdf"), + BINARY("application/octet-stream", SerializerDeserializer::serialize, "b", "binary"); @Getter @NonNull private final String contentType; diff --git a/src/main/java/ch/fritteli/labyrinth/server/StaticResourcesFileHandler.java b/src/main/java/ch/fritteli/labyrinth/server/StaticResourcesFileHandler.java new file mode 100644 index 0000000..d15f1fd --- /dev/null +++ b/src/main/java/ch/fritteli/labyrinth/server/StaticResourcesFileHandler.java @@ -0,0 +1,98 @@ +package ch.fritteli.labyrinth.server; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.io.IOUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; + +@Slf4j +public class StaticResourcesFileHandler implements HttpHandler { + + public static final String WEBASSETS_DIRECTORY = "webassets"; + private final ExecutorService executorService; + + public StaticResourcesFileHandler(final ExecutorService executorService) { + this.executorService = executorService; + } + + private static void redirect(@NonNull final HttpExchange exchange, @NonNull final String target) throws IOException { + log.debug("Sending redirect to {}", target); + exchange.getResponseHeaders().add("Location", target); + exchange.sendResponseHeaders(302, -1); + } + + private static void notFound(@NonNull final HttpExchange exchange, @NonNull final String path) throws IOException { + log.debug("Resource '{}' not found, replying with HTTP 404", path); + exchange.getResponseHeaders().add("Content-type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(404, 0); + exchange.getResponseBody().write("404 - Not found".getBytes(StandardCharsets.UTF_8)); + exchange.getResponseBody().flush(); + } + + @NonNull + private static byte[] getBytes(@NonNull final String path) throws IOException { + final InputStream stream = StaticResourcesFileHandler.class.getClassLoader().getResourceAsStream(WEBASSETS_DIRECTORY + path); + if (stream == null) { + log.debug("Resource '{}' not found in classpath.", path); + return new byte[0]; + } + return IOUtils.toByteArray(stream); + } + + @Nullable + private static String getMimeType(@NonNull final String path) { + if (path.endsWith(".html")) { + return "text/html"; + } + if (path.endsWith(".css")) { + return "text/css"; + } + return null; + } + + @Override + public void handle(@NonNull final HttpExchange exchange) throws IOException { + this.executorService.submit(() -> { + try { + final URI requestURI = exchange.getRequestURI(); + final String path = requestURI.getPath(); + log.debug("Handling request to {}", path); + if ("/".equals(path)) { + redirect(exchange, "/index.html"); + return; + } + if (!path.startsWith("/")) { + notFound(exchange, path); + return; + } + final String mimeType = getMimeType(path); + if (mimeType == null) { + notFound(exchange, path); + return; + } + final byte[] responseBytes = getBytes(path); + if (responseBytes.length == 0) { + notFound(exchange, path); + return; + } + log.debug("Serving {}{} with mimetype {}: {} bytes", WEBASSETS_DIRECTORY, path, mimeType, responseBytes.length); + exchange.getResponseHeaders().add("Content-type", mimeType); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().write(responseBytes); + exchange.getResponseBody().flush(); + } catch (Exception e) { + log.error("FSCK!", e); + } finally { + exchange.close(); + } + }); + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..08ae395 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/src/main/resources/webassets/index.html b/src/main/resources/webassets/index.html new file mode 100644 index 0000000..8248e96 --- /dev/null +++ b/src/main/resources/webassets/index.html @@ -0,0 +1,32 @@ + + + + + Labyrinth Generator + + + +
+

Labyrinth Generator

+

Enter some values, click the "Create!" button and see what happens!

+
+
+ + + + + +
+
+ + +
+
+
+ + diff --git a/src/main/resources/webassets/style.css b/src/main/resources/webassets/style.css new file mode 100644 index 0000000..153f8e8 --- /dev/null +++ b/src/main/resources/webassets/style.css @@ -0,0 +1,51 @@ +:root { + --color-background: #181a1b; + --color-foreground: #e8e6e3; + --color-border: #5d6164; + --color-background-highlight: #292b2c; + --color-foreground-highlight: #f8f7f4; + --color-border-highlight: #6e7275; +} + +body { + display: flex; + font-family: sans-serif; + justify-content: center; +} + +body, button, input, select { + background-color: var(--color-background); + color: var(--color-foreground); +} + +button, input, select { + border: 1px solid var(--color-border); +} + +button:active, input:active, select:active, +button:focus, input:focus, select:focus, +button:hover, input:hover, select:hover { + background-color: var(--color-background-highlight); + border-color: var(--color-border-highlight); + color: var(--color-foreground-highlight); +} + +button.primary { + background-color: #ffcc00; + border-color: #eebb00; + color: var(--color-background); +} + +.content { + width: 75%; +} + +.content h1 { + text-align: center; +} + +.inputs-wrapper { + display: grid; + grid-row-gap: 1em; + grid-template-columns: 0.5fr 1fr; +} diff --git a/src/test/java/ch/fritteli/labyrinth/server/ServerConfigTest.java b/src/test/java/ch/fritteli/labyrinth/server/ServerConfigTest.java index 277abd3..23e31d7 100644 --- a/src/test/java/ch/fritteli/labyrinth/server/ServerConfigTest.java +++ b/src/test/java/ch/fritteli/labyrinth/server/ServerConfigTest.java @@ -4,7 +4,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.net.UnknownHostException; -import java.util.Properties; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -12,7 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class ServerConfigTest { @BeforeEach void clearSysProperties() { - System.setProperties(new Properties()); + System.clearProperty(ServerConfig.SYSPROP_HOST); + System.clearProperty(ServerConfig.SYSPROP_PORT); } @Test