diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/BlueMapResponseModifier.java b/common/src/main/java/de/bluecolored/bluemap/common/web/BlueMapResponseModifier.java index 034da409a..25748f2e3 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/BlueMapResponseModifier.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/BlueMapResponseModifier.java @@ -49,8 +49,8 @@ public HttpResponse handle(HttpRequest request) { HttpResponse response = delegate.handle(request); HttpStatusCode status = response.getStatusCode(); - if (status.getCode() >= 400 && !response.hasData()){ - response.setData(status.getCode() + " - " + status.getMessage() + "\n" + this.serverName); + if (status.getCode() >= 400 && response.getBody() != null){ + response.setBody(status.getCode() + " - " + status.getMessage() + "\n" + this.serverName); } response.addHeader("Server", this.serverName); diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/FileRequestHandler.java b/common/src/main/java/de/bluecolored/bluemap/common/web/FileRequestHandler.java index 491f65c5c..393036225 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/FileRequestHandler.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/FileRequestHandler.java @@ -85,7 +85,7 @@ private HttpResponse generateResponse(HttpRequest request) throws IOException { // redirect to have correct relative paths if (Files.isDirectory(filePath) && !request.getPath().endsWith("/")) { HttpResponse response = new HttpResponse(HttpStatusCode.SEE_OTHER); - response.addHeader("Location", "/" + path + "/" + (request.getGETParamString().isEmpty() ? "" : "?" + request.getGETParamString())); + response.addHeader("Location", "/" + path + "/" + (request.getRawQueryString().isEmpty() ? "" : "?" + request.getRawQueryString())); return response; } @@ -151,7 +151,7 @@ private HttpResponse generateResponse(HttpRequest request) throws IOException { //send response try { - response.setData(Files.newInputStream(filePath)); + response.setBody(Files.newInputStream(filePath)); return response; } catch (FileNotFoundException e) { return new HttpResponse(HttpStatusCode.NOT_FOUND); diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/JsonDataRequestHandler.java b/common/src/main/java/de/bluecolored/bluemap/common/web/JsonDataRequestHandler.java index a4cc988b3..ffed5577b 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/JsonDataRequestHandler.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/JsonDataRequestHandler.java @@ -48,7 +48,7 @@ public HttpResponse handle(HttpRequest request) { HttpResponse response = new HttpResponse(HttpStatusCode.OK); response.addHeader("Cache-Control", "no-cache"); response.addHeader("Content-Type", "application/json"); - response.setData(dataSupplier.get()); + response.setBody(dataSupplier.get()); return response; } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/LoggingRequestHandler.java b/common/src/main/java/de/bluecolored/bluemap/common/web/LoggingRequestHandler.java index 2085b6565..15ff66bfb 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/LoggingRequestHandler.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/LoggingRequestHandler.java @@ -31,8 +31,6 @@ import lombok.NonNull; import lombok.Setter; -import java.net.URI; - @Getter @Setter @AllArgsConstructor public class LoggingRequestHandler implements HttpRequestHandler { @@ -65,7 +63,9 @@ public HttpResponse handle(HttpRequest request) { } String method = request.getMethod(); - URI address = request.getAddress(); + String path = request.getPath(); + String queryString = request.getRawQueryString(); + String address = queryString == null ? path : path + "?" + queryString; String version = request.getVersion(); // run request @@ -81,7 +81,7 @@ public HttpResponse handle(HttpRequest request) { source, xffSource, method, - address.toString(), + address, version, statusCode, statusMessage diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/MapStorageRequestHandler.java b/common/src/main/java/de/bluecolored/bluemap/common/web/MapStorageRequestHandler.java index c85c0f5ff..45c04521c 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/MapStorageRequestHandler.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/MapStorageRequestHandler.java @@ -122,7 +122,7 @@ private void writeToResponse(CompressedInputStream data, HttpResponse response, request.hasHeaderValue("Accept-Encoding", compression.getId()) ) { response.addHeader("Content-Encoding", compression.getId()); - response.setData(data); + response.setBody(data); } else if ( compression != Compression.GZIP && !response.hasHeaderValue("Content-Type", "image/png") && @@ -134,9 +134,9 @@ private void writeToResponse(CompressedInputStream data, HttpResponse response, data.decompress().transferTo(os); } byte[] compressedData = byteOut.toByteArray(); - response.setData(new ByteArrayInputStream(compressedData)); + response.setBody(new ByteArrayInputStream(compressedData)); } else { - response.setData(data.decompress()); + response.setBody(data.decompress()); } } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpConnection.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpConnection.java index 567d3e4ae..1b07dd69f 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpConnection.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpConnection.java @@ -25,134 +25,57 @@ package de.bluecolored.bluemap.common.web.http; import de.bluecolored.bluemap.core.logger.Logger; +import lombok.RequiredArgsConstructor; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.EOFException; import java.io.IOException; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.nio.channels.Channel; -import java.nio.channels.SelectableChannel; -import java.nio.channels.SelectionKey; -import java.nio.channels.SocketChannel; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; +import java.net.Socket; +import java.net.SocketTimeoutException; -public class HttpConnection implements SelectionConsumer { +public class HttpConnection implements Runnable { + private final Socket socket; + private final HttpRequestInputStream requestIn; + private final HttpResponseOutputStream responseOut; private final HttpRequestHandler requestHandler; - private final Executor responseHandlerExecutor; - private HttpRequest request; - private CompletableFuture futureResponse; - private HttpResponse response; - public HttpConnection(HttpRequestHandler requestHandler) { - this(requestHandler, Runnable::run); //run synchronously - } - - public HttpConnection(HttpRequestHandler requestHandler, Executor responseHandlerExecutor) { + public HttpConnection(Socket socket, HttpRequestHandler requestHandler) throws IOException { + this.socket = socket; this.requestHandler = requestHandler; - this.responseHandlerExecutor = responseHandlerExecutor; - } - - @Override - public void accept(SelectionKey selectionKey) { - if (!selectionKey.isValid()) return; - SelectableChannel selChannel = selectionKey.channel(); - - if (!(selChannel instanceof SocketChannel)) return; - SocketChannel channel = (SocketChannel) selChannel; + this.requestIn = new HttpRequestInputStream(new BufferedInputStream(socket.getInputStream()), socket.getInetAddress()); + this.responseOut = new HttpResponseOutputStream(new BufferedOutputStream(socket.getOutputStream())); + } + public void run() { try { + while (socket.isConnected() && !socket.isClosed() && !socket.isInputShutdown() && !socket.isOutputShutdown()) { + HttpRequest request = requestIn.read(); + if (request == null) continue; - if (request == null) { - SocketAddress remote = channel.getRemoteAddress(); - InetAddress remoteInet = null; - if (remote instanceof InetSocketAddress) - remoteInet = ((InetSocketAddress) remote).getAddress(); - - request = new HttpRequest(remoteInet); - } - - // receive request - if (!request.write(channel)) { - if (!selectionKey.isValid()) return; - selectionKey.interestOps(SelectionKey.OP_READ); - return; - } - - // process request - if (futureResponse == null) { - futureResponse = CompletableFuture.supplyAsync( - () -> requestHandler.handle(request), - responseHandlerExecutor - ); - futureResponse.handle((response, error) -> { - if (error != null) { - Logger.global.logError("Unexpected error handling request", error); - response = new HttpResponse(HttpStatusCode.INTERNAL_SERVER_ERROR); - } - - try { - response.read(channel); // do an initial read to trigger response sending intent - this.response = response; - } catch (IOException e) { - handleIOException(channel, e); - } - - return null; - }); - } - - if (response == null) return; - if (!selectionKey.isValid()) return; - - // send response - if (!response.read(channel)){ - selectionKey.interestOps(SelectionKey.OP_WRITE); - return; + try (HttpResponse response = requestHandler.handle(request)) { + responseOut.write(response); + } } - - // reset to accept new request - request.clear(); - response.close(); - futureResponse = null; - response = null; - selectionKey.interestOps(SelectionKey.OP_READ); - + } catch (EOFException | SocketTimeoutException ignore) { + // ignore known exceptions that happen when browsers or us close the connection } catch (IOException e) { - handleIOException(channel, e); - } - } - - private void handleIOException(Channel channel, IOException e) { - request.clear(); - - if (response != null) { + if ( // ignore known exceptions that happen when browsers close the connection + e.getMessage() == null || + !e.getMessage().equals("Broken pipe") + ) { + Logger.global.logDebug("Exception in HttpConnection: " + e); + } + } catch (Exception e) { + Logger.global.logDebug("Exception in HttpConnection: " + e); + } finally { try { - response.close(); - } catch (IOException e2) { - Logger.global.logWarning("Failed to close response: " + e2); + socket.close(); + } catch (IOException e) { + Logger.global.logDebug("Exception closing HttpConnection: " + e); } - response = null; - } - - if (futureResponse != null) { - futureResponse.thenAccept(response -> { - try { - response.close(); - } catch (IOException e2) { - Logger.global.logWarning("Failed to close response: " + e2); - } - }); - futureResponse = null; - } - - Logger.global.logDebug("Failed to process selection: " + e); - try { - channel.close(); - } catch (IOException e2) { - Logger.global.logWarning("Failed to close channel" + e2); } } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpHeader.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpHeader.java index 557bea7a0..8575eb3a4 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpHeader.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpHeader.java @@ -24,40 +24,52 @@ */ package de.bluecolored.bluemap.common.web.http; +import lombok.Getter; + import java.util.*; public class HttpHeader { - private final String key; - private final String value; + @Getter private final String key; + @Getter private String value; private List values; private Set valuesLC; - public HttpHeader(String key, String value) { + public HttpHeader(String key, String... values) { this.key = key; - this.value = value; + this.value = String.join(",", values); } - public String getKey() { - return key; + public synchronized void add(String... values) { + if (value.isEmpty()) { + set(values); + return; + } + + this.value = value + "," + String.join(",", values); + this.values = null; + this.valuesLC = null; } - public String getValue() { - return value; + public synchronized void set(String... values) { + this.value = String.join(",", values); + this.values = null; + this.valuesLC = null; } - public List getValues() { + public synchronized List getValues() { if (values == null) { - values = new ArrayList<>(); + List vs = new ArrayList<>(); for (String v : value.split(",")) { - values.add(v.trim()); + vs.add(v.trim()); } + values = Collections.unmodifiableList(vs); } return values; } - public boolean contains(String value) { + public synchronized boolean contains(String value) { if (valuesLC == null) { valuesLC = new HashSet<>(); for (String v : getValues()) { @@ -68,4 +80,21 @@ public boolean contains(String value) { return valuesLC.contains(value); } + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + HttpHeader that = (HttpHeader) o; + return Objects.equals(key, that.key) && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(key, value); + } + + @Override + public String toString() { + return key + ": " + value; + } + } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/SelectionConsumer.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpHeaderCarrier.java similarity index 68% rename from common/src/main/java/de/bluecolored/bluemap/common/web/http/SelectionConsumer.java rename to common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpHeaderCarrier.java index e8da4981c..5f7f8d45d 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/SelectionConsumer.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpHeaderCarrier.java @@ -24,7 +24,25 @@ */ package de.bluecolored.bluemap.common.web.http; -import java.nio.channels.SelectionKey; -import java.util.function.Consumer; +import java.util.Locale; +import java.util.Map; -public interface SelectionConsumer extends Consumer {} +public interface HttpHeaderCarrier { + + Map getHeaders(); + + default void addHeader(String name, String... values) { + getHeaders().put(name.toLowerCase(Locale.ROOT), new HttpHeader(name, values)); + } + + default HttpHeader getHeader(String key) { + return getHeaders().get(key.toLowerCase(Locale.ROOT)); + } + + default boolean hasHeaderValue(String key, String value) { + HttpHeader header = getHeader(key); + if (header == null) return false; + return header.contains(value); + } + +} diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpRequest.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpRequest.java index b77db35d6..7e4633afe 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpRequest.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpRequest.java @@ -24,262 +24,60 @@ */ package de.bluecolored.bluemap.common.web.http; +import lombok.*; +import org.jetbrains.annotations.Nullable; + import java.io.ByteArrayInputStream; -import java.io.IOException; import java.io.InputStream; import java.net.InetAddress; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.ByteBuffer; -import java.nio.channels.ReadableByteChannel; -import java.util.*; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class HttpRequest { - - private static final Pattern REQUEST_PATTERN = Pattern.compile("^(\\w+) (\\S+) (.+)$"); - - // reading helper - private final ByteBuffer byteBuffer = ByteBuffer.allocate(1024); - private final StringBuffer lineBuffer = new StringBuffer(); - - private boolean complete = false; - private boolean headerComplete = false; - private final List headerLines = new ArrayList<>(20); - - // request data - private final InetAddress source; - private URI address; - private String method, version; - private final Map headers = new HashMap<>(); - private byte[] data; - - // these values can be overwritten separately by a HttpRequestHandler for delegation - private String path = null; - private String getParamString = null; - private Map getParams = null; - - public HttpRequest(InetAddress source) { - this.source = source; - } - - public boolean write(ReadableByteChannel channel) throws IOException { - if (complete) return true; - - int read = channel.read(byteBuffer); - if (read == 0) return false; - if (read == -1) { - channel.close(); - return false; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@Getter +@Setter +@RequiredArgsConstructor +public class HttpRequest implements HttpHeaderCarrier { + + private @NonNull InetAddress source; + private @NonNull String method; + private @NonNull String path; + private @NonNull @Singular Map queryParams = new LinkedHashMap<>(); + private @NonNull String version = "HTTP/1.1"; + private @NonNull @Singular Map headers = new LinkedHashMap<>(); + private byte @NonNull [] body = new byte[0]; + + public String getQueryParam(String key) { + return queryParams.get(key); + } + + public String getRawQueryString() { + return queryParams.entrySet().stream() + .map(e -> e.getValue().isEmpty() ? e.getKey() : + URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + + "=" + + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8) + ) + .collect(Collectors.joining("&")); + } + + public void setRawQueryString(@Nullable String rawQueryString) { + queryParams.clear(); + if (rawQueryString == null) return; + for (String param : rawQueryString.split("&")){ + if (param.isEmpty()) continue; + String[] kv = param.split("=", 2); + String key = URLDecoder.decode(kv[0], StandardCharsets.UTF_8); + String value = kv.length > 1 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : ""; + queryParams.put(key, value); } - - byteBuffer.flip(); - try { - - // read headers - while (!headerComplete) { - if (!writeLine()) return false; - String line = lineBuffer.toString().stripTrailing(); - lineBuffer.setLength(0); - - if (line.isEmpty()) { - headerComplete = true; - parseHeaders(); - } else { - headerLines.add(line); - } - } - - if (hasHeaderValue("transfer-encoding", "chunked")) { - writeChunkedBody(); - } else { - HttpHeader contentLengthHeader = getHeader("content-length"); - int contentLength = 0; - if (contentLengthHeader != null) { - try { - contentLength = Integer.parseInt(contentLengthHeader.getValue().trim()); - } catch (NumberFormatException ex) { - throw new IOException("Invalid HTTP Request: content-length is not a number", ex); - } - } - - if (contentLength > 0) { - writeBody(contentLength); - } - } - - complete = true; - return true; - - } finally { - byteBuffer.compact(); - } - } - - private void writeChunkedBody() { - // TODO - } - - private void writeBody(int length) { - // TODO - } - - private void parseHeaders() throws IOException { - if (headerLines.isEmpty()) throw new IOException("Invalid HTTP Request: No Header"); - - Matcher m = REQUEST_PATTERN.matcher(headerLines.get(0)); - if (!m.find()) throw new IOException("Invalid HTTP Request: Request-Pattern not matching"); - - method = m.group(1); - if (method == null) throw new IOException("Invalid HTTP Request: Request-Pattern not matching (method)"); - - String addressString = m.group(2); - if (addressString == null) throw new IOException("Invalid HTTP Request: Request-Pattern not matching (address)"); - try { - address = new URI(addressString); - } catch (URISyntaxException ex) { - throw new IOException("Invalid HTTP Request: Request-URI is invalid", ex); - } - - version = m.group(3); - if (version == null) throw new IOException("Invalid HTTP Request: Request-Pattern not matching (version)"); - - headers.clear(); - for (int i = 1; i < headerLines.size(); i++) { - String line = headerLines.get(i); - if (line.trim().isEmpty()) continue; - - String[] kv = line.split(":", 2); - if (kv.length < 2) continue; - - headers.put(kv[0].trim().toLowerCase(Locale.ROOT), new HttpHeader(kv[0], kv[1])); - } - } - - private boolean writeLine() { - while (lineBuffer.length() <= 0 || lineBuffer.charAt(lineBuffer.length() - 1) != '\n'){ - if (!byteBuffer.hasRemaining()) return false; - lineBuffer.append((char) byteBuffer.get()); - } - return true; - } - - public InetAddress getSource() { - return source; - } - - public String getMethod() { - return method; } - public void setMethod(String method) { - this.method = method; - } - - public URI getAddress() { - return address; - } - - public void setAddress(URI address) { - this.address = address; - this.path = null; - this.getParams = null; - this.getParamString = null; - } - - public String getVersion() { - return version; - } - - public void setVersion(String version) { - this.version = version; - } - - public Map getHeaders() { - return headers; - } - - public HttpHeader getHeader(String header) { - return this.headers.get(header.toLowerCase(Locale.ROOT)); - } - - public boolean hasHeaderValue(String key, String value) { - HttpHeader header = getHeader(key); - if (header == null) return false; - return header.contains(value); - } - - public byte[] getData() { - return data; - } - - public InputStream getDataStream() { - return new ByteArrayInputStream(data); - } - - public String getPath() { - if (path == null) parseAddress(); - return path; - } - - public void setPath(String path) { - this.path = path; - } - - public Map getGETParams() { - if (getParams == null) parseGetParams(); - return getParams; - } - - public String getGETParamString() { - if (getParamString == null) parseAddress(); - return getParamString; - } - - public void setGetParamString(String getParamString) { - this.getParamString = getParamString; - this.getParams = null; - } - - private void parseAddress() { - this.path = address.getPath(); - this.getParamString = address.getQuery(); - } - - private void parseGetParams() { - Map getParams = new HashMap<>(); - for (String getParam : this.getGETParamString().split("&")){ - if (getParam.isEmpty()) continue; - String[] kv = getParam.split("=", 2); - String key = kv[0]; - String value = kv.length > 1 ? kv[1] : ""; - getParams.put(key, value); - } - this.getParams = getParams; - } - - public boolean isComplete() { - return complete; - } - - public void clear() { - byteBuffer.clear(); - lineBuffer.setLength(0); - - complete = false; - headerComplete = false; - headerLines.clear(); - - method = null; - address = null; - version = null; - headers.clear(); - data = null; - - path = null; - getParamString = null; - getParams = null; + public InputStream getBodyStream() { + return new ByteArrayInputStream(body); } } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpRequestInputStream.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpRequestInputStream.java new file mode 100644 index 000000000..aedba0d91 --- /dev/null +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpRequestInputStream.java @@ -0,0 +1,140 @@ +/* + * This file is part of BlueMap, licensed under the MIT License (MIT). + * + * Copyright (c) Blue (Lukas Rieger) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package de.bluecolored.bluemap.common.web.http; + +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.net.InetAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class HttpRequestInputStream implements Closeable { + + private static final Pattern REQUEST_PATTERN = Pattern.compile("^(\\w+) (\\S+) (.+)$"); + + private final InetAddress source; + private final DataInputStream in; + private final Reader reader; + + private byte[] byteBuffer = new byte[1024]; + private final char[] charBuffer = new char[1]; + + public HttpRequestInputStream(InputStream in, InetAddress source) { + this.source = source; + this.in = new DataInputStream(in); + this.reader = new InputStreamReader(this.in, StandardCharsets.UTF_8); + } + + public @Nullable HttpRequest read() throws IOException { + + String requestLine; + requestLine = readLine(); + + Matcher m = REQUEST_PATTERN.matcher(requestLine); + if (!m.find()) throw new IOException("Invalid HTTP Request: Request-Pattern not matching '%s'".formatted(requestLine)); + + URI address = URI.create(m.group(2)); + + HttpRequest request = new HttpRequest( + source, + m.group(1), + address.getPath() + ); + request.setVersion(m.group(3)); + request.setRawQueryString(address.getRawQuery()); + + // headers + while (true) { + String line = readLine(); + if (line.isBlank()) break; + + String[] kv = line.split(":", 2); + if (kv.length < 2) continue; + + request.addHeader(kv[0], kv[1].trim()); + } + + // body + if (request.hasHeaderValue("transfer-encoding", "chunked")) { + request.setBody(readChunkedBody()); + } else { + HttpHeader contentLengthHeader = request.getHeader("content-length"); + int contentLength = 0; + if (contentLengthHeader != null) { + try { + contentLength = Integer.parseInt(contentLengthHeader.getValue().trim()); + } catch (NumberFormatException ex) { + throw new IOException("Invalid HTTP Request: content-length is not a number", ex); + } + } + + if (contentLength > 0) { + request.setBody(readBody(contentLength)); + } + } + + return request; + } + + private String readLine() throws IOException { + + StringBuilder stringBuilder = new StringBuilder(); + do { + if (reader.read(charBuffer, 0, 1) == -1) throw new EOFException(); + stringBuilder.append(charBuffer, 0, 1); + } while (charBuffer[0] != '\n'); + + return stringBuilder.toString(); + } + + private byte[] readChunkedBody() throws IOException { + ByteArrayOutputStream body = new ByteArrayOutputStream(1024); + + while (true) { + String prefix = readLine(); + int size = Integer.valueOf(prefix.formatted(), 16); + if (size > byteBuffer.length) byteBuffer = new byte[size]; + size = in.readNBytes(byteBuffer, 0, size); + body.write(byteBuffer, 0, size); + readLine(); // suffix + if (size == 0) break; + } + + return body.toByteArray(); + } + + private byte[] readBody(int contentLength) throws IOException { + return in.readNBytes(contentLength); + } + + @Override + public void close() throws IOException { + in.close(); + } + +} diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponse.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponse.java index 8e4894c61..610ad3795 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponse.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponse.java @@ -24,187 +24,49 @@ */ package de.bluecolored.bluemap.common.web.http; +import lombok.*; +import org.jetbrains.annotations.Nullable; + import java.io.*; -import java.nio.ByteBuffer; -import java.nio.channels.Channels; -import java.nio.channels.ReadableByteChannel; -import java.nio.channels.WritableByteChannel; import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Locale; +import java.util.LinkedHashMap; import java.util.Map; -public class HttpResponse implements Closeable { - - private static final byte[] CHUNK_SUFFIX = "\r\n".getBytes(StandardCharsets.UTF_8); - - private String version; - private HttpStatusCode statusCode; - private final Map headers; - private ReadableByteChannel data; +@Getter +@Setter +@RequiredArgsConstructor +public class HttpResponse implements Closeable, HttpHeaderCarrier { - private ByteBuffer headerData; - private ByteBuffer dataBuffer; - private boolean complete = false; - private boolean headerComplete = false; - private boolean dataChannelComplete = false; - private boolean dataComplete = false; + private @NonNull String version = "HTTP/1.1"; + private @NonNull HttpStatusCode statusCode; + private @NonNull @Singular Map headers = new LinkedHashMap<>(); + private @Nullable InputStream body; - public HttpResponse(HttpStatusCode statusCode) { - this.version = "HTTP/1.1"; - this.statusCode = statusCode; - - this.headers = new HashMap<>(); + public void setBody(@Nullable InputStream body) { + this.body = body; } - public synchronized boolean read(WritableByteChannel channel) throws IOException { - if (complete) return true; - - // send headers - if (!headerComplete) { - if (headerData == null) writeHeaderData(); - if (headerData.hasRemaining()) { - channel.write(headerData); - } - - if (headerData.hasRemaining()) return false; - headerComplete = true; - headerData = null; // free ram - } - - if (!hasData()){ - complete = true; - return true; - } - - // send data chunked - if (dataBuffer == null) dataBuffer = ByteBuffer.allocate(1024 + 200).flip(); // 200 extra bytes - while (true) { - if (dataBuffer.hasRemaining()) channel.write(dataBuffer); - if (dataBuffer.hasRemaining()) return false; - if (dataComplete) break; // nothing more to do - - // fill data buffer from channel - dataBuffer.clear(); - dataBuffer.position(100); // keep 100 space in front - dataBuffer.limit(1124); // keep 100 space at the end - - int readTotal = 0; - if (!dataChannelComplete) { - int read = 0; - while (dataBuffer.hasRemaining() && (read = data.read(dataBuffer)) != -1) { - readTotal += read; - } - - if (read == -1) dataChannelComplete = true; - } - - if (readTotal == 0) dataComplete = true; - - byte[] chunkPrefix = (Integer.toHexString(readTotal) + "\r\n") - .getBytes(StandardCharsets.UTF_8); - - dataBuffer.limit(dataBuffer.capacity()); - dataBuffer.put(CHUNK_SUFFIX); - dataBuffer.limit(dataBuffer.position()); - - int startPos = 100 - chunkPrefix.length; - dataBuffer.position(startPos); - dataBuffer.put(chunkPrefix); - dataBuffer.position(startPos); + public void setBody(byte[] data) { + if (data == null) { + this.body = null; + return; } - complete = true; - return true; + setBody(new ByteArrayInputStream(data)); } - private void writeHeaderData() { - ByteArrayOutputStream headerDataOut = new ByteArrayOutputStream(); - - if (hasData()){ - headers.put("Transfer-Encoding", new HttpHeader("Transfer-Encoding", "chunked")); - } else { - headers.put("Content-Length", new HttpHeader("Content-Length", "0")); + public void setBody(String data) { + if (data == null) { + this.body = null; + return; } - headerDataOut.writeBytes((version + " " + statusCode.getCode() + " " + statusCode.getMessage() + "\r\n") - .getBytes(StandardCharsets.UTF_8)); - for (HttpHeader header : headers.values()){ - headerDataOut.writeBytes((header.getKey() + ": " + header.getValue() + "\r\n") - .getBytes(StandardCharsets.UTF_8)); - } - headerDataOut.writeBytes(("\r\n") - .getBytes(StandardCharsets.UTF_8)); - - headerData = ByteBuffer.allocate(headerDataOut.size()) - .put(headerDataOut.toByteArray()) - .flip(); - } - - public void addHeader(String key, String value){ - HttpHeader header; - HttpHeader existing = getHeader(key); - if (existing != null) { - header = new HttpHeader(existing.getKey(), existing.getValue() + ", " + value); - } else { - header = new HttpHeader(key, value); - } - this.headers.put(key.toLowerCase(Locale.ROOT), header); - } - - public void setData(ReadableByteChannel channel){ - this.data = channel; - } - - public void setData(InputStream dataStream){ - this.data = Channels.newChannel(dataStream); - } - - public void setData(String data){ - setData(new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8))); - } - - public boolean hasData() { - return this.data != null; - } - - public boolean isComplete() { - return complete; + setBody(data.getBytes(StandardCharsets.UTF_8)); } @Override public void close() throws IOException { - if (data != null) data.close(); - } - - public HttpStatusCode getStatusCode(){ - return statusCode; - } - - public void setStatusCode(HttpStatusCode statusCode) { - this.statusCode = statusCode; - } - - public String getVersion() { - return version; - } - - public void setVersion(String version) { - this.version = version; - } - - public Map getHeaders() { - return headers; - } - - public HttpHeader getHeader(String header) { - return this.headers.get(header.toLowerCase(Locale.ROOT)); - } - - public boolean hasHeaderValue(String key, String value) { - HttpHeader header = getHeader(key); - if (header == null) return false; - return header.contains(value); + if (body != null) body.close(); } } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponseOutputStream.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponseOutputStream.java new file mode 100644 index 000000000..ea1abb715 --- /dev/null +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponseOutputStream.java @@ -0,0 +1,94 @@ +/* + * This file is part of BlueMap, licensed under the MIT License (MIT). + * + * Copyright (c) Blue (Lukas Rieger) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package de.bluecolored.bluemap.common.web.http; + +import lombok.RequiredArgsConstructor; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +@RequiredArgsConstructor +public class HttpResponseOutputStream implements Closeable { + + private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); + + private final OutputStream outputStream; + + private final byte[] byteBuffer = new byte[1024]; + + public void write(HttpResponse response) throws IOException { + HttpStatusCode statusCode = response.getStatusCode(); + InputStream body = response.getBody(); + + writeLine(response.getVersion() + " " + statusCode.getCode() + " " + statusCode.getMessage()); + + // headers + if (body != null) { + response.addHeader("Transfer-Encoding","chunked"); + } else { + response.addHeader("Content-Length", "0"); + } + for (HttpHeader header : response.getHeaders().values()) { + writeLine(header.getKey() + ": " + header.getValue()); + } + writeLine(); + + // body + if (body != null) { + + while (true) { + int read = body.read(byteBuffer); + if (read == -1) break; + if (read == 0) continue; + writeLine(Integer.toHexString(read)); + outputStream.write(byteBuffer, 0, read); + writeLine(); + } + + writeLine(Integer.toHexString(0)); + writeLine(); + } + + outputStream.flush(); + } + + private void writeLine() throws IOException { + outputStream.write(CRLF); + } + + private void writeLine(String line) throws IOException { + outputStream.write(line.getBytes(StandardCharsets.UTF_8)); + outputStream.write(CRLF); + } + + @Override + public void close() throws IOException { + outputStream.close(); + } + +} diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpServer.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpServer.java index d8e8c3546..05b0fab7b 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpServer.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpServer.java @@ -28,19 +28,29 @@ import lombok.Setter; import java.io.IOException; +import java.nio.channels.SocketChannel; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class HttpServer extends Server { @Getter @Setter private HttpRequestHandler requestHandler; + private ExecutorService executor; - public HttpServer(HttpRequestHandler requestHandler) throws IOException { + public HttpServer(HttpRequestHandler requestHandler, ExecutorService executor) throws IOException { this.requestHandler = requestHandler; + this.executor = executor; + } + + public HttpServer(HttpRequestHandler requestHandler) throws IOException { + this(requestHandler, Executors.newVirtualThreadPerTaskExecutor()); } @Override - public SelectionConsumer createConnectionHandler() { - return new HttpConnection(requestHandler); + public void handleConnection(SocketChannel connection) throws IOException { + connection.socket().setSoTimeout(600000); // set a 10 min max idle timeout + executor.execute(new HttpConnection(connection.socket(), requestHandler)); } } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/Server.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/Server.java index 2e2924b7f..f27d625c7 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/Server.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/Server.java @@ -45,12 +45,12 @@ public Server() throws IOException { this.server = new ArrayList<>(); } - public abstract SelectionConsumer createConnectionHandler(); + public abstract void handleConnection(SocketChannel connection) throws IOException; public void bind(SocketAddress address) throws IOException { final ServerSocketChannel server = ServerSocketChannel.open(); server.configureBlocking(false); - server.register(selector, SelectionKey.OP_ACCEPT, (SelectionConsumer) this::accept); + server.register(selector, SelectionKey.OP_ACCEPT); server.bind(address); this.server.add(server); @@ -62,8 +62,7 @@ public void bind(SocketAddress address) throws IOException { } private boolean checkIfBoundToAllInterfaces(SocketAddress address) { - if (address instanceof InetSocketAddress) { - InetSocketAddress inetAddress = (InetSocketAddress) address; + if (address instanceof InetSocketAddress inetAddress) { return Objects.equals(inetAddress.getAddress(), new InetSocketAddress(0).getAddress()); } @@ -75,28 +74,20 @@ public void run() { Logger.global.logInfo("WebServer started."); while (this.selector.isOpen()) { try { - this.selector.select(this::selection); + this.selector.select(this::accept); } catch (IOException e) { Logger.global.logDebug("Failed to select channel: " + e); } catch (ClosedSelectorException ignore) {} } } - private void selection(SelectionKey selectionKey) { - Object attachment = selectionKey.attachment(); - if (attachment instanceof SelectionConsumer) { - ((SelectionConsumer) attachment).accept(selectionKey); - } - } - private void accept(SelectionKey selectionKey) { try { //noinspection resource ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel(); SocketChannel channel = serverSocketChannel.accept(); if (channel == null) return; - channel.configureBlocking(false); - channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE, createConnectionHandler()); + handleConnection(channel); } catch (IOException e) { Logger.global.logDebug("Failed to accept connection: " + e); }