1967 lines
86 KiB
Java
1967 lines
86 KiB
Java
package io.gitlab.jfronny.commons.http.server;
|
|
|
|
import io.gitlab.jfronny.commons.http.server.api.ContextHandler;
|
|
import io.gitlab.jfronny.commons.http.server.io.ChunkedInputStream;
|
|
import io.gitlab.jfronny.commons.http.server.io.ChunkedOutputStream;
|
|
import io.gitlab.jfronny.commons.http.server.io.LimitedInputStream;
|
|
import io.gitlab.jfronny.commons.http.server.util.FileContextHandler;
|
|
import io.gitlab.jfronny.commons.http.server.util.Header;
|
|
import io.gitlab.jfronny.commons.http.server.util.Headers;
|
|
import io.gitlab.jfronny.commons.http.server.util.VirtualHost;
|
|
|
|
import javax.net.ServerSocketFactory;
|
|
import javax.net.ssl.SSLServerSocketFactory;
|
|
import javax.net.ssl.SSLSocket;
|
|
import java.io.*;
|
|
import java.net.*;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.text.ParseException;
|
|
import java.text.SimpleDateFormat;
|
|
import java.util.*;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.concurrent.Executor;
|
|
import java.util.concurrent.Executors;
|
|
import java.util.zip.DeflaterOutputStream;
|
|
import java.util.zip.GZIPOutputStream;
|
|
|
|
/**
|
|
* The {@code HTTPServer} class implements a light-weight HTTP server.
|
|
* <p>
|
|
* This server implements all functionality required by RFC 2616 ("Hypertext
|
|
* Transfer Protocol -- HTTP/1.1"), as well as some of the optional
|
|
* functionality (this is termed "conditionally compliant" in the RFC).
|
|
* In fact, a couple of bugs in the RFC itself were discovered
|
|
* (and fixed) during the development of this server.
|
|
* <p>
|
|
* <b>Feature Overview</b>
|
|
* <ul>
|
|
* <li>RFC compliant - correctness is not sacrificed for the sake of size</li>
|
|
* <li>Virtual hosts - multiple domains and subdomains per server</li>
|
|
* <li>File serving - built-in handler to serve files and folders from disk</li>
|
|
* <li>Mime type mappings - configurable via API or a standard mime.types file</li>
|
|
* <li>Directory index generation - enables browsing folder contents</li>
|
|
* <li>Welcome files - configurable default filename (e.g. index.html)</li>
|
|
* <li>All HTTP methods supported - GET/HEAD/OPTIONS/TRACE/POST/PUT/DELETE/custom</li>
|
|
* <li>Conditional statuses - ETags and If-* header support</li>
|
|
* <li>Chunked transfer encoding - for serving dynamically-generated data streams</li>
|
|
* <li>Gzip/deflate compression - reduces bandwidth and download time</li>
|
|
* <li>HTTPS - secures all server communications</li>
|
|
* <li>Partial content - download continuation (a.k.a. byte range serving)</li>
|
|
* <li>File upload - multipart/form-data handling as stream or iterator</li>
|
|
* <li>Multiple context handlers - a different handler method per URL path</li>
|
|
* <li>@Context annotations - auto-detection of context handler methods</li>
|
|
* <li>Parameter parsing - from query string or x-www-form-urlencoded body</li>
|
|
* <li>A single source file - super-easy to integrate into any application</li>
|
|
* <li>Standalone - no dependencies other than the Java runtime</li>
|
|
* <li>Small footprint - standard jar is ~50K, stripped jar is ~35K</li>
|
|
* <li>Extensible design - easy to override, add or remove functionality</li>
|
|
* <li>Reusable utility methods to simplify your custom code</li>
|
|
* <li>Extensive documentation of API and implementation (>40% of source lines)</li>
|
|
* </ul>
|
|
* <p>
|
|
* <b>Use Cases</b>
|
|
* <p>
|
|
* Being a lightweight, standalone, easily embeddable and tiny-footprint
|
|
* server, it is well-suited for
|
|
* <ul>
|
|
* <li>Resource-constrained environments such as embedded devices.
|
|
* For really extreme constraints, you can easily remove unneeded
|
|
* functionality to make it even smaller (and use the -Dstripped
|
|
* maven build option to strip away debug info, license, etc.)</li>
|
|
* <li>Unit and integration tests - fast setup/teardown times, small overhead
|
|
* and simple context handler setup make it a great web server for testing
|
|
* client components under various server response conditions.</li>
|
|
* <li>Embedding a web console into any headless application for
|
|
* administration, monitoring, or a full portable GUI.</li>
|
|
* <li>A full-fledged standalone web server serving static files,
|
|
* dynamically-generated content, REST APIs, pseudo-streaming, etc.</li>
|
|
* <li>A good reference for learning how HTTP works under the hood.</li>
|
|
* </ul>
|
|
* <p>
|
|
* <b>Implementation Notes</b>
|
|
* <p>
|
|
* The design and implementation of this server attempt to balance correctness,
|
|
* compliance, readability, size, features, extensibility and performance,
|
|
* and often prioritize them in this order, but some trade-offs must be made.
|
|
* <p>
|
|
* This server is multithreaded in its support for multiple concurrent HTTP
|
|
* connections, however most of its constituent classes are not thread-safe and
|
|
* require external synchronization if accessed by multiple threads concurrently.
|
|
* <p>
|
|
* <b>Source Structure and Documentation</b>
|
|
* <p>
|
|
* This server is intentionally written as a single source file, in order to make
|
|
* it as easy as possible to integrate into any existing project - by simply adding
|
|
* this single file to the project sources. It does, however, aim to maintain a
|
|
* structured and flexible design. There are no external package dependencies.
|
|
* <p>
|
|
* This file contains extensive documentation of its classes and methods, as
|
|
* well as implementation details and references to specific RFC sections
|
|
* which clarify the logic behind the code. It is recommended that anyone
|
|
* attempting to modify the protocol-level functionality become acquainted with
|
|
* the RFC, in order to make sure that protocol compliance is not broken.
|
|
* <p>
|
|
* <b>Getting Started</b>
|
|
* <p>
|
|
* For an example and a good starting point for learning how to use the API,
|
|
* see the {@link #main main} method at the bottom of the file, and follow
|
|
* the code into the API from there. Alternatively, you can just browse through
|
|
* the classes and utility methods and read their documentation and code.
|
|
*
|
|
* @author Amichai Rothman
|
|
* @since 2008-07-24
|
|
*/
|
|
public class JLHTTPServer {
|
|
private static final System.Logger LOGGER = System.getLogger("commons-http-server");
|
|
|
|
/**
|
|
* The SimpleDateFormat-compatible formats of dates which must be supported.
|
|
* Note that all generated date fields must be in the RFC 1123 format only,
|
|
* while the others are supported by recipients for backwards-compatibility.
|
|
*/
|
|
public static final String[] DATE_PATTERNS = {
|
|
"EEE, dd MMM yyyy HH:mm:ss z", // RFC 822, updated by RFC 1123
|
|
"EEEE, dd-MMM-yy HH:mm:ss z", // RFC 850, obsoleted by RFC 1036
|
|
"EEE MMM d HH:mm:ss yyyy" // ANSI C's asctime() format
|
|
};
|
|
|
|
/** A GMT (UTC) timezone instance. */
|
|
protected static final TimeZone GMT = TimeZone.getTimeZone("GMT");
|
|
|
|
/** Date format strings. */
|
|
protected static final char[]
|
|
DAYS = "Sun Mon Tue Wed Thu Fri Sat".toCharArray(),
|
|
MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".toCharArray();
|
|
|
|
/** A convenience array containing the carriage-return and line feed chars. */
|
|
public static final byte[] CRLF = { 0x0d, 0x0a };
|
|
|
|
/** The HTTP status description strings. */
|
|
protected static final String[] statuses = new String[600];
|
|
|
|
static {
|
|
// initialize status descriptions lookup table
|
|
Arrays.fill(statuses, "Unknown Status");
|
|
statuses[100] = "Continue";
|
|
statuses[200] = "OK";
|
|
statuses[204] = "No Content";
|
|
statuses[206] = "Partial Content";
|
|
statuses[301] = "Moved Permanently";
|
|
statuses[302] = "Found";
|
|
statuses[304] = "Not Modified";
|
|
statuses[307] = "Temporary Redirect";
|
|
statuses[400] = "Bad Request";
|
|
statuses[401] = "Unauthorized";
|
|
statuses[403] = "Forbidden";
|
|
statuses[404] = "Not Found";
|
|
statuses[405] = "Method Not Allowed";
|
|
statuses[408] = "Request Timeout";
|
|
statuses[412] = "Precondition Failed";
|
|
statuses[413] = "Request Entity Too Large";
|
|
statuses[414] = "Request-URI Too Large";
|
|
statuses[416] = "Requested Range Not Satisfiable";
|
|
statuses[417] = "Expectation Failed";
|
|
statuses[500] = "Internal Server Error";
|
|
statuses[501] = "Not Implemented";
|
|
statuses[502] = "Bad Gateway";
|
|
statuses[503] = "Service Unavailable";
|
|
statuses[504] = "Gateway Time-out";
|
|
}
|
|
|
|
/**
|
|
* A mapping of path suffixes (e.g. file extensions) to their
|
|
* corresponding MIME types.
|
|
*/
|
|
protected static final Map<String, String> contentTypes =
|
|
new ConcurrentHashMap<>();
|
|
|
|
static {
|
|
// add some default common content types
|
|
// see http://www.iana.org/assignments/media-types/ for full list
|
|
addContentType("application/font-woff", "woff");
|
|
addContentType("application/font-woff2", "woff2");
|
|
addContentType("application/java-archive", "jar");
|
|
addContentType("application/javascript", "js");
|
|
addContentType("application/json", "json");
|
|
addContentType("application/octet-stream", "exe");
|
|
addContentType("application/pdf", "pdf");
|
|
addContentType("application/x-7z-compressed", "7z");
|
|
addContentType("application/x-compressed", "tgz");
|
|
addContentType("application/x-gzip", "gz");
|
|
addContentType("application/x-tar", "tar");
|
|
addContentType("application/xhtml+xml", "xhtml");
|
|
addContentType("application/zip", "zip");
|
|
addContentType("audio/mpeg", "mp3");
|
|
addContentType("image/gif", "gif");
|
|
addContentType("image/jpeg", "jpg", "jpeg");
|
|
addContentType("image/png", "png");
|
|
addContentType("image/svg+xml", "svg");
|
|
addContentType("image/x-icon", "ico");
|
|
addContentType("text/css", "css");
|
|
addContentType("text/csv", "csv");
|
|
addContentType("text/html; charset=utf-8", "htm", "html");
|
|
addContentType("text/plain", "txt", "text", "log");
|
|
addContentType("text/xml", "xml");
|
|
}
|
|
|
|
/** The MIME types that can be compressed (prefix/suffix wildcards allowed). */
|
|
protected static String[] compressibleContentTypes =
|
|
{ "text/*", "*/javascript", "*icon", "*+xml", "*/json" };
|
|
|
|
/**
|
|
* The {@code Request} class encapsulates a single HTTP request.
|
|
*/
|
|
public class Request {
|
|
|
|
protected String method;
|
|
protected URI uri;
|
|
protected URL baseURL; // cached value
|
|
protected String version;
|
|
protected Headers headers;
|
|
protected InputStream body;
|
|
protected Map<String, String> params; // cached value
|
|
protected VirtualHost host; // cached value
|
|
protected VirtualHost.ContextInfo context; // cached value
|
|
|
|
/**
|
|
* Constructs a Request from the data in the given input stream.
|
|
*
|
|
* @param in the input stream from which the request is read
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public Request(InputStream in) throws IOException {
|
|
readRequestLine(in);
|
|
headers = readHeaders(in);
|
|
// RFC2616#3.6 - if "chunked" is used, it must be the last one
|
|
// RFC2616#4.4 - if non-identity Transfer-Encoding is present,
|
|
// it must either include "chunked" or close the connection after
|
|
// the body, and in any case ignore Content-Length.
|
|
// if there is no such Transfer-Encoding, use Content-Length
|
|
// if neither header exists, there is no body
|
|
String header = headers.get("Transfer-Encoding");
|
|
if (header != null && !header.toLowerCase(Locale.US).equals("identity")) {
|
|
if (Arrays.asList(splitElements(header, true)).contains("chunked"))
|
|
body = new ChunkedInputStream(in, headers);
|
|
else
|
|
body = in; // body ends when connection closes
|
|
} else {
|
|
header = headers.get("Content-Length");
|
|
long len = header == null ? 0 : parseULong(header, 10);
|
|
body = new LimitedInputStream(in, len, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the request method.
|
|
*
|
|
* @return the request method
|
|
*/
|
|
public String getMethod() { return method; }
|
|
|
|
/**
|
|
* Returns the request URI.
|
|
*
|
|
* @return the request URI
|
|
*/
|
|
public URI getURI() { return uri; }
|
|
|
|
/**
|
|
* Returns the request version string.
|
|
*
|
|
* @return the request version string
|
|
*/
|
|
public String getVersion() { return version; }
|
|
|
|
/**
|
|
* Returns the request headers.
|
|
*
|
|
* @return the request headers
|
|
*/
|
|
public Headers getHeaders() { return headers; }
|
|
|
|
/**
|
|
* Returns the input stream containing the request body.
|
|
*
|
|
* @return the input stream containing the request body
|
|
*/
|
|
public InputStream getBody() { return body; }
|
|
|
|
/**
|
|
* Returns the path component of the request URI, after
|
|
* URL decoding has been applied (using the UTF-8 charset).
|
|
*
|
|
* @return the decoded path component of the request URI
|
|
*/
|
|
public String getPath() {
|
|
return uri.getPath();
|
|
}
|
|
|
|
/**
|
|
* Sets the path component of the request URI. This can be useful
|
|
* in URL rewriting, etc.
|
|
*
|
|
* @param path the path to set
|
|
* @throws IllegalArgumentException if the given path is malformed
|
|
*/
|
|
public void setPath(String path) {
|
|
try {
|
|
uri = new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(),
|
|
trimDuplicates(path, '/'), uri.getQuery(), uri.getFragment());
|
|
context = null; // clear cached context so it will be recalculated
|
|
} catch (URISyntaxException use) {
|
|
throw new IllegalArgumentException("error setting path", use);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the base URL (scheme, host and port) of the request resource.
|
|
* The host name is taken from the request URI or the Host header or a
|
|
* default host (see RFC2616#5.2).
|
|
*
|
|
* @return the base URL of the requested resource, or null if it
|
|
* is malformed
|
|
*/
|
|
public URL getBaseURL() {
|
|
if (baseURL != null)
|
|
return baseURL;
|
|
// normalize host header
|
|
String host = uri.getHost();
|
|
if (host == null) {
|
|
host = headers.get("Host");
|
|
if (host == null) // missing in HTTP/1.0
|
|
host = detectLocalHostName();
|
|
}
|
|
int pos = host.indexOf(':');
|
|
host = pos < 0 ? host : host.substring(0, pos);
|
|
try {
|
|
return baseURL = new URL(secure ? "https" : "http", host, port, "");
|
|
} catch (MalformedURLException mue) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the request parameters, which are parsed both from the query
|
|
* part of the request URI, and from the request body if its content
|
|
* type is "application/x-www-form-urlencoded" (i.e. a submitted form).
|
|
* UTF-8 encoding is assumed in both cases.
|
|
* <p>
|
|
* The parameters are returned as a list of string arrays, each containing
|
|
* the parameter name as the first element and its corresponding value
|
|
* as the second element (or an empty string if there is no value).
|
|
* <p>
|
|
* The list retains the original order of the parameters.
|
|
*
|
|
* @return the request parameters name-value pairs,
|
|
* or an empty list if there are none
|
|
* @throws IOException if an error occurs
|
|
* @see JLHTTPServer#parseParamsList(String)
|
|
*/
|
|
public List<String[]> getParamsList() throws IOException {
|
|
List<String[]> queryParams = parseParamsList(uri.getRawQuery());
|
|
List<String[]> bodyParams = Collections.emptyList();
|
|
String ct = headers.get("Content-Type");
|
|
if (ct != null && ct.toLowerCase(Locale.US).startsWith("application/x-www-form-urlencoded"))
|
|
bodyParams = parseParamsList(readToken(body, -1, "UTF-8", 2097152)); // 2MB limit
|
|
if (bodyParams.isEmpty())
|
|
return queryParams;
|
|
if (queryParams.isEmpty())
|
|
return bodyParams;
|
|
queryParams.addAll(bodyParams);
|
|
return queryParams;
|
|
}
|
|
|
|
/**
|
|
* Returns the request parameters, which are parsed both from the query
|
|
* part of the request URI, and from the request body if its content
|
|
* type is "application/x-www-form-urlencoded" (i.e. a submitted form).
|
|
* UTF-8 encoding is assumed in both cases.
|
|
* <p>
|
|
* For multivalued parameters (i.e. multiple parameters with the same
|
|
* name), only the first one is considered. For access to all values,
|
|
* use {@link #getParamsList()} instead.
|
|
* <p>
|
|
* The map iteration retains the original order of the parameters.
|
|
*
|
|
* @return the request parameters name-value pairs,
|
|
* or an empty map if there are none
|
|
* @throws IOException if an error occurs
|
|
* @see #getParamsList()
|
|
*/
|
|
public Map<String, String> getParams() throws IOException {
|
|
if (params == null)
|
|
params = toMap(getParamsList());
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Returns the absolute (zero-based) content range value read
|
|
* from the Range header. If multiple ranges are requested, a single
|
|
* range containing all of them is returned.
|
|
*
|
|
* @param length the full length of the requested resource
|
|
* @return the requested range, or null if the Range header
|
|
* is missing or invalid
|
|
*/
|
|
public long[] getRange(long length) {
|
|
String header = headers.get("Range");
|
|
return header == null || !header.startsWith("bytes=")
|
|
? null : parseRange(header.substring(6), length);
|
|
}
|
|
|
|
/**
|
|
* Reads the request line, parsing the method, URI and version string.
|
|
*
|
|
* @param in the input stream from which the request line is read
|
|
* @throws IOException if an error occurs or the request line is invalid
|
|
*/
|
|
protected void readRequestLine(InputStream in) throws IOException {
|
|
// RFC2616#4.1: should accept empty lines before request line
|
|
// RFC2616#19.3: tolerate additional whitespace between tokens
|
|
String line;
|
|
try {
|
|
do { line = readLine(in); } while (line.length() == 0);
|
|
} catch (IOException ioe) { // if EOF, timeout etc.
|
|
throw new IOException("missing request line"); // signal that the request did not begin
|
|
}
|
|
String[] tokens = split(line, " ", -1);
|
|
if (tokens.length != 3)
|
|
throw new IOException("invalid request line: \"" + line + "\"");
|
|
try {
|
|
method = tokens[0];
|
|
// must remove '//' prefix which constructor parses as host name
|
|
uri = new URI(trimDuplicates(tokens[1], '/'));
|
|
version = tokens[2]; // RFC2616#2.1: allow implied LWS; RFC7230#3.1.1: disallow it
|
|
} catch (URISyntaxException use) {
|
|
throw new IOException("invalid URI: " + use.getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the virtual host corresponding to the requested host name,
|
|
* or the default host if none exists.
|
|
*
|
|
* @return the virtual host corresponding to the requested host name,
|
|
* or the default virtual host
|
|
*/
|
|
public VirtualHost getVirtualHost() {
|
|
if (host != null) return host;
|
|
URL baseUrl = getBaseURL();
|
|
if (baseUrl != null && (host = JLHTTPServer.this.getVirtualHost(baseUrl.getHost())) != null) return host;
|
|
return host = JLHTTPServer.this.getVirtualHost(null);
|
|
}
|
|
|
|
/**
|
|
* Returns the info of the context handling this request.
|
|
*
|
|
* @return the info of the context handling this request, or an empty context
|
|
*/
|
|
public VirtualHost.ContextInfo getContext() {
|
|
return context != null ? context : (context = getVirtualHost().getContext(getPath()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The {@code Response} class encapsulates a single HTTP response.
|
|
*/
|
|
public class Response implements Closeable {
|
|
|
|
protected OutputStream out; // the underlying output stream
|
|
protected OutputStream[] encoders = new OutputStream[4]; // chained encoder streams
|
|
protected Headers headers;
|
|
protected boolean discardBody;
|
|
protected int state; // nothing sent, headers sent, or closed
|
|
protected Request req; // request used in determining client capabilities
|
|
|
|
/**
|
|
* Constructs a Response whose output is written to the given stream.
|
|
*
|
|
* @param out the stream to which the response is written
|
|
*/
|
|
public Response(OutputStream out) {
|
|
this.out = out;
|
|
this.headers = new Headers();
|
|
}
|
|
|
|
/**
|
|
* Sets whether this response's body is discarded or sent.
|
|
*
|
|
* @param discardBody specifies whether the body is discarded or not
|
|
*/
|
|
public void setDiscardBody(boolean discardBody) {
|
|
this.discardBody = discardBody;
|
|
}
|
|
|
|
/**
|
|
* Sets the request which is used in determining the capabilities
|
|
* supported by the client (e.g. compression, encoding, etc.)
|
|
*
|
|
* @param req the request
|
|
*/
|
|
public void setClientCapabilities(Request req) { this.req = req; }
|
|
|
|
/**
|
|
* Returns the request headers collection.
|
|
*
|
|
* @return the request headers collection
|
|
*/
|
|
public Headers getHeaders() { return headers; }
|
|
|
|
/**
|
|
* Returns the underlying output stream to which the response is written.
|
|
* Except for special cases, you should use {@link #getBody()} instead.
|
|
*
|
|
* @return the underlying output stream to which the response is written
|
|
*/
|
|
public OutputStream getOutputStream() { return out; }
|
|
|
|
/**
|
|
* Returns whether the response headers were already sent.
|
|
*
|
|
* @return whether the response headers were already sent
|
|
*/
|
|
public boolean headersSent() { return state == 1; }
|
|
|
|
/**
|
|
* Returns an output stream into which the response body can be written.
|
|
* The stream applies encodings (e.g. compression) according to the sent headers.
|
|
* This method must be called after response headers have been sent
|
|
* that indicate there is a body. Normally, the content should be
|
|
* prepared (not sent) even before the headers are sent, so that any
|
|
* errors during processing can be caught and a proper error response returned -
|
|
* after the headers are sent, it's too late to change the status into an error.
|
|
*
|
|
* @return an output stream into which the response body can be written,
|
|
* or null if the body should not be written (e.g. it is discarded)
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public OutputStream getBody() throws IOException {
|
|
if (encoders[0] != null || discardBody)
|
|
return encoders[0]; // return the existing stream (or null)
|
|
// set up chain of encoding streams according to headers
|
|
List<String> te = Arrays.asList(splitElements(headers.get("Transfer-Encoding"), true));
|
|
List<String> ce = Arrays.asList(splitElements(headers.get("Content-Encoding"), true));
|
|
int i = encoders.length - 1;
|
|
encoders[i] = new FilterOutputStream(out) {
|
|
@Override
|
|
public void close() {} // keep underlying connection stream open for now
|
|
@Override // override the very inefficient default implementation
|
|
public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); }
|
|
};
|
|
if (te.contains("chunked"))
|
|
encoders[--i] = new ChunkedOutputStream(encoders[i + 1]);
|
|
if (ce.contains("gzip") || te.contains("gzip"))
|
|
encoders[--i] = new GZIPOutputStream(encoders[i + 1], 4096);
|
|
else if (ce.contains("deflate") || te.contains("deflate"))
|
|
encoders[--i] = new DeflaterOutputStream(encoders[i + 1]);
|
|
encoders[0] = encoders[i];
|
|
encoders[i] = null; // prevent duplicate reference
|
|
return encoders[0]; // returned stream is always first
|
|
}
|
|
|
|
/**
|
|
* Closes this response and flushes all output.
|
|
*
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public void close() throws IOException {
|
|
state = -1; // closed
|
|
if (encoders[0] != null)
|
|
encoders[0].close(); // close all chained streams (except the underlying one)
|
|
out.flush(); // always flush underlying stream (even if getBody was never called)
|
|
}
|
|
|
|
/**
|
|
* Sends the response headers with the given response status.
|
|
* A Date header is added if it does not already exist.
|
|
* If the response has a body, the Content-Length/Transfer-Encoding
|
|
* and Content-Type headers must be set before sending the headers.
|
|
*
|
|
* @param status the response status
|
|
* @throws IOException if an error occurs or headers were already sent
|
|
* @see #sendHeaders(int, long, long, String, String, long[])
|
|
*/
|
|
public void sendHeaders(int status) throws IOException {
|
|
if (headersSent())
|
|
throw new IOException("headers were already sent");
|
|
if (!headers.contains("Date"))
|
|
headers.add("Date", formatDate(System.currentTimeMillis()));
|
|
headers.add("Server", "JLHTTP/2.5");
|
|
out.write(getBytes("HTTP/1.1 ", Integer.toString(status), " ", statuses[status]));
|
|
out.write(CRLF);
|
|
headers.writeTo(out);
|
|
state = 1; // headers sent
|
|
}
|
|
|
|
/**
|
|
* Sends the response headers, including the given response status
|
|
* and description, and all response headers. If they do not already
|
|
* exist, the following headers are added as necessary:
|
|
* Content-Range, Content-Type, Transfer-Encoding, Content-Encoding,
|
|
* Content-Length, Last-Modified, ETag, Connection and Date. Ranges are
|
|
* properly calculated as well, with a 200 status changed to a 206 status.
|
|
*
|
|
* @param status the response status
|
|
* @param length the response body length, or zero if there is no body,
|
|
* or negative if there is a body but its length is not yet known
|
|
* @param lastModified the last modified date of the response resource,
|
|
* or non-positive if unknown. A time in the future will be
|
|
* replaced with the current system time.
|
|
* @param etag the ETag of the response resource, or null if unknown
|
|
* (see RFC2616#3.11)
|
|
* @param contentType the content type of the response resource, or null
|
|
* if unknown (in which case "application/octet-stream" will be sent)
|
|
* @param range the content range that will be sent, or null if the
|
|
* entire resource will be sent
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public void sendHeaders(int status, long length, long lastModified,
|
|
String etag, String contentType, long[] range) throws IOException {
|
|
if (range != null) {
|
|
headers.add("Content-Range", "bytes " + range[0] + "-" +
|
|
range[1] + "/" + (length >= 0 ? length : "*"));
|
|
length = range[1] - range[0] + 1;
|
|
if (status == 200)
|
|
status = 206;
|
|
}
|
|
String ct = headers.get("Content-Type");
|
|
if (ct == null) {
|
|
ct = contentType != null ? contentType : "application/octet-stream";
|
|
headers.add("Content-Type", ct);
|
|
}
|
|
if (!headers.contains("Content-Length") && !headers.contains("Transfer-Encoding")) {
|
|
// RFC2616#3.6: transfer encodings are case-insensitive and must not be sent to an HTTP/1.0 client
|
|
boolean modern = req != null && req.getVersion().endsWith("1.1");
|
|
String accepted = req == null ? null : req.getHeaders().get("Accept-Encoding");
|
|
List<String> encodings = Arrays.asList(splitElements(accepted, true));
|
|
String compression = encodings.contains("gzip") ? "gzip" :
|
|
encodings.contains("deflate") ? "deflate" : null;
|
|
if (compression != null && (length < 0 || length > 300) && isCompressible(ct) && modern) {
|
|
headers.add("Transfer-Encoding", "chunked"); // compressed data is always unknown length
|
|
headers.add("Content-Encoding", compression);
|
|
} else if (length < 0 && modern) {
|
|
headers.add("Transfer-Encoding", "chunked"); // unknown length
|
|
} else if (length >= 0) {
|
|
headers.add("Content-Length", Long.toString(length)); // known length
|
|
}
|
|
}
|
|
if (!headers.contains("Vary")) // RFC7231#7.1.4: Vary field should include headers
|
|
headers.add("Vary", "Accept-Encoding"); // that are used in selecting representation
|
|
if (lastModified > 0 && !headers.contains("Last-Modified")) // RFC2616#14.29
|
|
headers.add("Last-Modified", formatDate(Math.min(lastModified, System.currentTimeMillis())));
|
|
if (etag != null && !headers.contains("ETag"))
|
|
headers.add("ETag", etag);
|
|
if (req != null && "close".equalsIgnoreCase(req.getHeaders().get("Connection"))
|
|
&& !headers.contains("Connection"))
|
|
headers.add("Connection", "close"); // #RFC7230#6.6: should reply to close with close
|
|
sendHeaders(status);
|
|
}
|
|
|
|
/**
|
|
* Sends the full response with the given status, and the given string
|
|
* as the body. The text is sent in the UTF-8 charset. If a
|
|
* Content-Type header was not explicitly set, it will be set to
|
|
* text/html, and so the text must contain valid (and properly
|
|
* {@link JLHTTPServer#escapeHTML escaped}) HTML.
|
|
*
|
|
* @param status the response status
|
|
* @param text the text body (sent as text/html)
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public void send(int status, String text) throws IOException {
|
|
byte[] content = text.getBytes(StandardCharsets.UTF_8);
|
|
sendHeaders(status, content.length, -1,
|
|
"W/\"" + Integer.toHexString(text.hashCode()) + "\"",
|
|
"text/html; charset=utf-8", null);
|
|
OutputStream out = getBody();
|
|
if (out != null)
|
|
out.write(content);
|
|
}
|
|
|
|
/**
|
|
* Sends an error response with the given status and detailed message.
|
|
* An HTML body is created containing the status and its description,
|
|
* as well as the message, which is escaped using the
|
|
* {@link JLHTTPServer#escapeHTML escape} method.
|
|
*
|
|
* @param status the response status
|
|
* @param text the text body (sent as text/html)
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public void sendError(int status, String text) throws IOException {
|
|
send(status, String.format(
|
|
"<!DOCTYPE html>%n<html>%n<head><title>%d %s</title></head>%n" +
|
|
"<body><h1>%d %s</h1>%n<p>%s</p>%n</body></html>",
|
|
status, statuses[status], status, statuses[status], escapeHTML(text)));
|
|
}
|
|
|
|
/**
|
|
* Sends an error response with the given status and default body.
|
|
*
|
|
* @param status the response status
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public void sendError(int status) throws IOException {
|
|
String text = status < 400 ? ":)" : "sorry it didn't work out :(";
|
|
sendError(status, text);
|
|
}
|
|
|
|
/**
|
|
* Sends the response body. This method must be called only after the
|
|
* response headers have been sent (and indicate that there is a body).
|
|
*
|
|
* @param body a stream containing the response body
|
|
* @param length the full length of the response body, or -1 for the whole stream
|
|
* @param range the sub-range within the response body that should be
|
|
* sent, or null if the entire body should be sent
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public void sendBody(InputStream body, long length, long[] range) throws IOException {
|
|
OutputStream out = getBody();
|
|
if (out != null) {
|
|
if (range != null) {
|
|
long offset = range[0];
|
|
length = range[1] - range[0] + 1;
|
|
while (offset > 0) {
|
|
long skip = body.skip(offset);
|
|
if (skip == 0)
|
|
throw new IOException("can't skip to " + range[0]);
|
|
offset -= skip;
|
|
}
|
|
}
|
|
transfer(body, out, length);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a 301 or 302 response, redirecting the client to the given URL.
|
|
*
|
|
* @param url the absolute URL to which the client is redirected
|
|
* @param permanent specifies whether a permanent (301) or
|
|
* temporary (302) redirect status is sent
|
|
* @throws IOException if an IO error occurs or url is malformed
|
|
*/
|
|
public void redirect(String url, boolean permanent) throws IOException {
|
|
try {
|
|
url = new URI(url).toASCIIString();
|
|
} catch (URISyntaxException e) {
|
|
throw new IOException("malformed URL: " + url);
|
|
}
|
|
headers.add("Location", url);
|
|
// some user-agents expect a body, so we send it
|
|
if (permanent)
|
|
sendError(301, "Permanently moved to " + url);
|
|
else
|
|
sendError(302, "Temporarily moved to " + url);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The {@code SocketHandlerThread} handles accepted sockets.
|
|
*/
|
|
protected class SocketHandlerThread extends Thread {
|
|
@Override
|
|
public void run() {
|
|
setName(getClass().getSimpleName() + "-" + port);
|
|
try {
|
|
ServerSocket serv = JLHTTPServer.this.serv; // keep local to avoid NPE when stopped
|
|
while (serv != null && !serv.isClosed()) {
|
|
final Socket sock = serv.accept();
|
|
executor.execute(() -> {
|
|
try {
|
|
try {
|
|
sock.setSoTimeout(socketTimeout);
|
|
sock.setTcpNoDelay(true); // we buffer anyway, so improve latency
|
|
handleConnection(sock.getInputStream(), sock.getOutputStream());
|
|
} finally {
|
|
try {
|
|
// RFC7230#6.6 - close socket gracefully
|
|
// (except SSL socket which doesn't support half-closing)
|
|
if (!(sock instanceof SSLSocket)) {
|
|
sock.shutdownOutput(); // half-close socket (only output)
|
|
transfer(sock.getInputStream(), null, -1); // consume input
|
|
}
|
|
} finally {
|
|
sock.close(); // and finally close socket fully
|
|
}
|
|
}
|
|
} catch (IOException ignore) {}
|
|
});
|
|
}
|
|
} catch (IOException ignore) {}
|
|
}
|
|
}
|
|
|
|
protected volatile int port;
|
|
protected volatile int socketTimeout = 10000;
|
|
protected volatile ServerSocketFactory serverSocketFactory;
|
|
protected volatile boolean secure;
|
|
protected volatile Executor executor;
|
|
protected volatile ServerSocket serv;
|
|
protected final Map<String, VirtualHost> hosts = new ConcurrentHashMap<>();
|
|
|
|
/**
|
|
* Constructs an HTTPServer which can accept connections on the given port.
|
|
* Note: the {@link #start()} method must be called to start accepting
|
|
* connections.
|
|
*
|
|
* @param port the port on which this server will accept connections
|
|
*/
|
|
public JLHTTPServer(int port) {
|
|
setPort(port);
|
|
addVirtualHost(new VirtualHost(null)); // add default virtual host
|
|
}
|
|
|
|
/**
|
|
* Constructs an HTTPServer which can accept connections on the default HTTP port 80.
|
|
* Note: the {@link #start()} method must be called to start accepting connections.
|
|
*/
|
|
public JLHTTPServer() {
|
|
this(80);
|
|
}
|
|
|
|
/**
|
|
* Sets the port on which this server will accept connections.
|
|
*
|
|
* @param port the port on which this server will accept connections
|
|
*/
|
|
public void setPort(int port) {
|
|
this.port = port;
|
|
}
|
|
|
|
/**
|
|
* Sets the factory used to create the server socket.
|
|
* If null or not set, the default {@link ServerSocketFactory#getDefault()} is used.
|
|
* For secure sockets (HTTPS), use an SSLServerSocketFactory instance.
|
|
* The port should usually also be changed for HTTPS, e.g. port 443 instead of 80.
|
|
* <p>
|
|
* If using the default SSLServerSocketFactory returned by
|
|
* {@link SSLServerSocketFactory#getDefault()}, the appropriate system properties
|
|
* must be set to configure the default JSSE provider, such as
|
|
* {@code javax.net.ssl.keyStore} and {@code javax.net.ssl.keyStorePassword}.
|
|
*
|
|
* @param factory the server socket factory to use
|
|
*/
|
|
public void setServerSocketFactory(ServerSocketFactory factory) {
|
|
this.serverSocketFactory = factory;
|
|
this.secure = factory instanceof SSLServerSocketFactory;
|
|
}
|
|
|
|
/**
|
|
* Sets the socket timeout for established connections.
|
|
*
|
|
* @param timeout the socket timeout in milliseconds
|
|
*/
|
|
public void setSocketTimeout(int timeout) { this.socketTimeout = timeout; }
|
|
|
|
/**
|
|
* Sets the executor used in servicing HTTP connections.
|
|
* If null, a default executor is used. The caller is responsible
|
|
* for shutting down the provided executor when necessary.
|
|
*
|
|
* @param executor the executor to use
|
|
*/
|
|
public void setExecutor(Executor executor) {
|
|
this.executor = executor;
|
|
}
|
|
|
|
/**
|
|
* Returns the virtual host with the given name.
|
|
*
|
|
* @param name the name of the virtual host to return,
|
|
* or null for the default virtual host
|
|
* @return the virtual host with the given name, or null if it doesn't exist
|
|
*/
|
|
public VirtualHost getVirtualHost(String name) {
|
|
return hosts.get(name == null ? "" : name);
|
|
}
|
|
|
|
/**
|
|
* Returns all virtual hosts.
|
|
*
|
|
* @return all virtual hosts (as an unmodifiable set)
|
|
*/
|
|
public Set<VirtualHost> getVirtualHosts() {
|
|
return Set.copyOf(hosts.values());
|
|
}
|
|
|
|
/**
|
|
* Adds the given virtual host to the server.
|
|
* If the host's name or aliases already exist, they are overwritten.
|
|
*
|
|
* @param host the virtual host to add
|
|
*/
|
|
public void addVirtualHost(VirtualHost host) {
|
|
String name = host.getName();
|
|
hosts.put(name == null ? "" : name, host);
|
|
}
|
|
|
|
/**
|
|
* Creates the server socket used to accept connections, using the configured
|
|
* {@link #setServerSocketFactory ServerSocketFactory} and {@link #setPort port}.
|
|
* <p>
|
|
* Cryptic errors seen here often mean the factory configuration details are wrong.
|
|
*
|
|
* @return the created server socket
|
|
* @throws IOException if the socket cannot be created
|
|
*/
|
|
protected ServerSocket createServerSocket() throws IOException {
|
|
ServerSocket serv = serverSocketFactory.createServerSocket();
|
|
serv.setReuseAddress(true);
|
|
serv.bind(new InetSocketAddress(port));
|
|
return serv;
|
|
}
|
|
|
|
/**
|
|
* Starts this server. If it is already started, does nothing.
|
|
* Note: Once the server is started, configuration-altering methods
|
|
* of the server and its virtual hosts must not be used. To modify the
|
|
* configuration, the server must first be stopped.
|
|
*
|
|
* @throws IOException if the server cannot begin accepting connections
|
|
*/
|
|
public synchronized void start() throws IOException {
|
|
if (serv != null)
|
|
return;
|
|
if (serverSocketFactory == null) // assign default server socket factory if needed
|
|
serverSocketFactory = ServerSocketFactory.getDefault(); // plain sockets
|
|
serv = createServerSocket();
|
|
if (executor == null) // assign default executor if needed
|
|
executor = Executors.newCachedThreadPool(); // consumes no resources when idle
|
|
// register all host aliases (which may have been modified)
|
|
for (VirtualHost host : getVirtualHosts())
|
|
for (String alias : host.getAliases())
|
|
hosts.put(alias, host);
|
|
// start handling incoming connections
|
|
new SocketHandlerThread().start();
|
|
}
|
|
|
|
/**
|
|
* Stops this server. If it is already stopped, does nothing.
|
|
* Note that if an {@link #setExecutor Executor} was set, it must be closed separately.
|
|
*/
|
|
public synchronized void stop() {
|
|
try {
|
|
if (serv != null)
|
|
serv.close();
|
|
} catch (IOException ignore) {}
|
|
serv = null;
|
|
}
|
|
|
|
/**
|
|
* Handles communications for a single connection over the given streams.
|
|
* Multiple subsequent transactions are handled on the connection,
|
|
* until the streams are closed, an error occurs, or the request
|
|
* contains a "Connection: close" header which explicitly requests
|
|
* the connection be closed after the transaction ends.
|
|
*
|
|
* @param in the stream from which the incoming requests are read
|
|
* @param out the stream into which the outgoing responses are written
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
protected void handleConnection(InputStream in, OutputStream out) throws IOException {
|
|
in = new BufferedInputStream(in, 4096);
|
|
out = new BufferedOutputStream(out, 4096);
|
|
Request req;
|
|
Response resp;
|
|
do {
|
|
// create request and response and handle transaction
|
|
req = null;
|
|
resp = new Response(out);
|
|
try {
|
|
req = new Request(in);
|
|
handleTransaction(req, resp);
|
|
} catch (Throwable t) { // unhandled errors (not normal error responses like 404)
|
|
if (req == null) { // error reading request
|
|
if (t instanceof IOException && t.getMessage().contains("missing request line"))
|
|
break; // we're not in the middle of a transaction - so just disconnect
|
|
resp.getHeaders().add("Connection", "close"); // about to close connection
|
|
if (t instanceof InterruptedIOException) // e.g. SocketTimeoutException
|
|
resp.sendError(408, "Timeout waiting for client request");
|
|
else
|
|
resp.sendError(400, "Invalid request: " + t.getMessage());
|
|
} else if (!resp.headersSent()) { // if headers were not already sent, we can send an error response
|
|
resp = new Response(out); // ignore whatever headers may have already been set
|
|
resp.getHeaders().add("Connection", "close"); // about to close connection
|
|
resp.sendError(500, "Error processing request: " + t.getMessage());
|
|
LOGGER.log(System.Logger.Level.WARNING, "Error processing request", t);
|
|
} // otherwise just abort the connection since we can't recover
|
|
break; // proceed to close connection
|
|
} finally {
|
|
resp.close(); // close response and flush output
|
|
}
|
|
// consume any leftover body data so next request can be processed
|
|
transfer(req.getBody(), null, -1);
|
|
// RFC7230#6.6: persist connection unless client or server close explicitly (or legacy client)
|
|
} while (!"close".equalsIgnoreCase(req.getHeaders().get("Connection"))
|
|
&& !"close".equalsIgnoreCase(resp.getHeaders().get("Connection")) && req.getVersion().endsWith("1.1"));
|
|
}
|
|
|
|
/**
|
|
* Handles a single transaction on a connection.
|
|
* <p>
|
|
* Subclasses can override this method to perform filtering on the
|
|
* request or response, apply wrappers to them, or further customize
|
|
* the transaction processing in some other way.
|
|
*
|
|
* @param req the transaction request
|
|
* @param resp the transaction response (into which the response is written)
|
|
* @throws IOException if and error occurs
|
|
*/
|
|
protected void handleTransaction(Request req, Response resp) throws IOException {
|
|
resp.setClientCapabilities(req);
|
|
if (preprocessTransaction(req, resp))
|
|
handleMethod(req, resp);
|
|
}
|
|
|
|
/**
|
|
* Preprocesses a transaction, performing various validation checks
|
|
* and required special header handling, possibly returning an
|
|
* appropriate response.
|
|
*
|
|
* @param req the request
|
|
* @param resp the response
|
|
* @return whether further processing should be performed on the transaction
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
protected boolean preprocessTransaction(Request req, Response resp) throws IOException {
|
|
Headers reqHeaders = req.getHeaders();
|
|
// validate request
|
|
String version = req.getVersion();
|
|
if (version.equals("HTTP/1.1")) {
|
|
if (!reqHeaders.contains("Host")) {
|
|
// RFC2616#14.23: missing Host header gets 400
|
|
resp.sendError(400, "Missing required Host header");
|
|
return false;
|
|
}
|
|
// return a continue response before reading body
|
|
String expect = reqHeaders.get("Expect");
|
|
if (expect != null) {
|
|
if (expect.equalsIgnoreCase("100-continue")) {
|
|
Response tempResp = new Response(resp.getOutputStream());
|
|
tempResp.sendHeaders(100);
|
|
resp.getOutputStream().flush();
|
|
} else {
|
|
// RFC2616#14.20: if unknown expect, send 417
|
|
resp.sendError(417);
|
|
return false;
|
|
}
|
|
}
|
|
} else if (version.equals("HTTP/1.0") || version.equals("HTTP/0.9")) {
|
|
// RFC2616#14.10 - remove connection headers from older versions
|
|
for (String token : splitElements(reqHeaders.get("Connection"), false))
|
|
reqHeaders.remove(token);
|
|
} else {
|
|
resp.sendError(400, "Unknown version: " + version);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handles a transaction according to the request method.
|
|
*
|
|
* @param req the transaction request
|
|
* @param resp the transaction response (into which the response is written)
|
|
* @throws IOException if and error occurs
|
|
*/
|
|
protected void handleMethod(Request req, Response resp) throws IOException {
|
|
String method = req.getMethod();
|
|
Map<String, ContextHandler> handlers = req.getContext().getHandlers();
|
|
// RFC 2616#5.1.1 - GET and HEAD must be supported
|
|
if (method.equals("GET") || handlers.containsKey(method)) {
|
|
serve(req, resp); // method is handled by context handler (or 404)
|
|
} else if (method.equals("HEAD")) { // default HEAD handler
|
|
req.method = "GET"; // identical to a GET
|
|
resp.setDiscardBody(true); // process normally but discard body
|
|
serve(req, resp);
|
|
} else if (method.equals("TRACE")) { // default TRACE handler
|
|
handleTrace(req, resp);
|
|
} else {
|
|
Set<String> methods = new LinkedHashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS")); // built-in methods
|
|
// "*" is a special server-wide (no-context) request supported by OPTIONS
|
|
boolean isServerOptions = req.getPath().equals("*") && method.equals("OPTIONS");
|
|
methods.addAll(isServerOptions ? req.getVirtualHost().getMethods() : handlers.keySet());
|
|
resp.getHeaders().add("Allow", join(", ", methods));
|
|
if (method.equals("OPTIONS")) { // default OPTIONS handler
|
|
resp.getHeaders().add("Content-Length", "0"); // RFC2616#9.2
|
|
resp.sendHeaders(200);
|
|
} else if (req.getVirtualHost().getMethods().contains(method)) {
|
|
resp.sendHeaders(405); // supported by server, but not this context (nor built-in)
|
|
} else {
|
|
resp.sendError(501); // unsupported method
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles a TRACE method request.
|
|
*
|
|
* @param req the request
|
|
* @param resp the response into which the content is written
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public void handleTrace(Request req, Response resp) throws IOException {
|
|
resp.sendHeaders(200, -1, -1, null, "message/http", null);
|
|
OutputStream out = resp.getBody();
|
|
out.write(getBytes("TRACE ", req.getURI().toString(), " ", req.getVersion()));
|
|
out.write(CRLF);
|
|
req.getHeaders().writeTo(out);
|
|
transfer(req.getBody(), out, -1);
|
|
}
|
|
|
|
/**
|
|
* Serves the content for a request by invoking the context
|
|
* handler for the requested context (path) and HTTP method.
|
|
*
|
|
* @param req the request
|
|
* @param resp the response into which the content is written
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
protected void serve(Request req, Response resp) throws IOException {
|
|
// get context handler to handle request
|
|
ContextHandler handler = req.getContext().getHandlers().get(req.getMethod());
|
|
if (handler == null) {
|
|
resp.sendError(404);
|
|
return;
|
|
}
|
|
// serve request
|
|
int status = 404;
|
|
// add directory index if necessary
|
|
String path = req.getPath();
|
|
if (path.endsWith("/")) {
|
|
String index = req.getVirtualHost().getDirectoryIndex();
|
|
if (index != null) {
|
|
req.setPath(path + index);
|
|
status = handler.serve(req, resp);
|
|
req.setPath(path);
|
|
}
|
|
}
|
|
if (status == 404)
|
|
status = handler.serve(req, resp);
|
|
if (status > 0)
|
|
resp.sendError(status);
|
|
}
|
|
|
|
/**
|
|
* Adds a Content-Type mapping for the given path suffixes.
|
|
* If any of the path suffixes had a previous Content-Type associated
|
|
* with it, it is replaced with the given one. Path suffixes are
|
|
* considered case-insensitive, and contentType is converted to lowercase.
|
|
*
|
|
* @param contentType the content type (MIME type) to be associated with
|
|
* the given path suffixes
|
|
* @param suffixes the path suffixes which will be associated with
|
|
* the contentType, e.g. the file extensions of served files
|
|
* (excluding the '.' character)
|
|
*/
|
|
public static void addContentType(String contentType, String... suffixes) {
|
|
for (String suffix : suffixes)
|
|
contentTypes.put(suffix.toLowerCase(Locale.US), contentType.toLowerCase(Locale.US));
|
|
}
|
|
|
|
/**
|
|
* Adds Content-Type mappings from a standard mime.types file.
|
|
*
|
|
* @param in a stream containing a mime.types file
|
|
* @throws IOException if an error occurs
|
|
* @throws FileNotFoundException if the file is not found or cannot be read
|
|
*/
|
|
public static void addContentTypes(InputStream in) throws IOException {
|
|
try (in) {
|
|
while (true) {
|
|
String line = readLine(in).trim(); // throws EOFException when done
|
|
if (line.length() > 0 && line.charAt(0) != '#') {
|
|
String[] tokens = split(line, " \t", -1);
|
|
for (int i = 1; i < tokens.length; i++)
|
|
addContentType(tokens[0], tokens[i]);
|
|
}
|
|
}
|
|
} catch (EOFException ignore) { // the end of file was reached - it's ok
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the content type for the given path, according to its suffix,
|
|
* or the given default content type if none can be determined.
|
|
*
|
|
* @param path the path whose content type is requested
|
|
* @param def a default content type which is returned if none can be
|
|
* determined
|
|
* @return the content type for the given path, or the given default
|
|
*/
|
|
public static String getContentType(String path, String def) {
|
|
int dot = path.lastIndexOf('.');
|
|
String type = dot < 0 ? def : contentTypes.get(path.substring(dot + 1).toLowerCase(Locale.US));
|
|
return type != null ? type : def;
|
|
}
|
|
|
|
/**
|
|
* Checks whether data of the given content type (MIME type) is compressible.
|
|
*
|
|
* @param contentType the content type
|
|
* @return true if the data is compressible, false if not
|
|
*/
|
|
public static boolean isCompressible(String contentType) {
|
|
int pos = contentType.indexOf(';'); // exclude params
|
|
String ct = pos < 0 ? contentType : contentType.substring(0, pos);
|
|
for (String s : compressibleContentTypes)
|
|
if (s.equals(ct) || s.charAt(0) == '*' && ct.endsWith(s.substring(1))
|
|
|| s.charAt(s.length() - 1) == '*' && ct.startsWith(s.substring(0, s.length() - 1)))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns the local host's auto-detected name.
|
|
*
|
|
* @return the local host name
|
|
*/
|
|
public static String detectLocalHostName() {
|
|
try {
|
|
return InetAddress.getLocalHost().getCanonicalHostName();
|
|
} catch (UnknownHostException uhe) {
|
|
return "localhost";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses name-value pair parameters from the given "x-www-form-urlencoded"
|
|
* MIME-type string. This is the encoding used both for parameters passed
|
|
* as the query of an HTTP GET method, and as the content of HTML forms
|
|
* submitted using the HTTP POST method (as long as they use the default
|
|
* "application/x-www-form-urlencoded" encoding in their ENCTYPE attribute).
|
|
* UTF-8 encoding is assumed.
|
|
* <p>
|
|
* The parameters are returned as a list of string arrays, each containing
|
|
* the parameter name as the first element and its corresponding value
|
|
* as the second element (or an empty string if there is no value).
|
|
* <p>
|
|
* The list retains the original order of the parameters.
|
|
*
|
|
* @param s an "application/x-www-form-urlencoded" string
|
|
* @return the parameter name-value pairs parsed from the given string,
|
|
* or an empty list if there are none
|
|
*/
|
|
public static List<String[]> parseParamsList(String s) {
|
|
if (s == null || s.length() == 0)
|
|
return Collections.emptyList();
|
|
List<String[]> params = new ArrayList<>(8);
|
|
for (String pair : split(s, "&", -1)) {
|
|
int pos = pair.indexOf('=');
|
|
String name = pos < 0 ? pair : pair.substring(0, pos);
|
|
String val = pos < 0 ? "" : pair.substring(pos + 1);
|
|
name = URLDecoder.decode(name.trim(), StandardCharsets.UTF_8);
|
|
val = URLDecoder.decode(val.trim(), StandardCharsets.UTF_8);
|
|
if (name.length() > 0)
|
|
params.add(new String[] { name, val });
|
|
}
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Converts a collection of pairs of objects (arrays of size two,
|
|
* each representing a key and corresponding value) into a Map.
|
|
* Duplicate keys are ignored (only the first occurrence of each key is considered).
|
|
* The map retains the original collection's iteration order.
|
|
*
|
|
* @param pairs a collection of arrays, each containing a key and corresponding value
|
|
* @param <K> the key type
|
|
* @param <V> the value type
|
|
* @return a map containing the paired keys and values, or an empty map
|
|
*/
|
|
@SuppressWarnings("unchecked")
|
|
public static <K, V> Map<K, V> toMap(Collection<? extends Object[]> pairs) {
|
|
if (pairs == null || pairs.isEmpty())
|
|
return Collections.emptyMap();
|
|
Map<K, V> map = new LinkedHashMap<>(pairs.size());
|
|
for (Object[] pair : pairs)
|
|
if (!map.containsKey(pair[0]))
|
|
map.put((K)pair[0], (V)pair[1]);
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Returns the absolute (zero-based) content range value specified
|
|
* by the given range string. If multiple ranges are requested, a single
|
|
* range containing all of them is returned.
|
|
*
|
|
* @param range the string containing the range description
|
|
* @param length the full length of the requested resource
|
|
* @return the requested range, or null if the range value is invalid
|
|
*/
|
|
public static long[] parseRange(String range, long length) {
|
|
long min = Long.MAX_VALUE;
|
|
long max = Long.MIN_VALUE;
|
|
try {
|
|
for (String token : splitElements(range, false)) {
|
|
long start, end;
|
|
int dash = token.indexOf('-');
|
|
if (dash == 0) { // suffix range
|
|
start = length - parseULong(token.substring(1), 10);
|
|
end = length - 1;
|
|
} else if (dash == token.length() - 1) { // open range
|
|
start = parseULong(token.substring(0, dash), 10);
|
|
end = length - 1;
|
|
} else { // explicit range
|
|
start = parseULong(token.substring(0, dash), 10);
|
|
end = parseULong(token.substring(dash + 1), 10);
|
|
}
|
|
if (end < start)
|
|
throw new RuntimeException();
|
|
if (start < min)
|
|
min = start;
|
|
if (end > max)
|
|
max = end;
|
|
}
|
|
if (max < 0) // no tokens
|
|
throw new RuntimeException();
|
|
if (max >= length && min < length)
|
|
max = length - 1;
|
|
return new long[] { min, max }; // start might be >= length!
|
|
} catch (RuntimeException re) { // NFE, IOOBE or explicit RE
|
|
return null; // RFC2616#14.35.1 - ignore header if invalid
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses an unsigned long value. This method behaves the same as calling
|
|
* {@link Long#parseLong(String, int)}, but considers the string invalid
|
|
* if it starts with an ASCII minus sign ('-') or plus sign ('+').
|
|
*
|
|
* @param s the String containing the long representation to be parsed
|
|
* @param radix the radix to be used while parsing s
|
|
* @return the long represented by s in the specified radix
|
|
* @throws NumberFormatException if the string does not contain a parsable
|
|
* long, or if it starts with an ASCII minus sign or plus sign
|
|
*/
|
|
public static long parseULong(String s, int radix) throws NumberFormatException {
|
|
long val = Long.parseLong(s, radix); // throws NumberFormatException
|
|
if (s.charAt(0) == '-' || s.charAt(0) == '+')
|
|
throw new NumberFormatException("invalid digit: " + s.charAt(0));
|
|
return val;
|
|
}
|
|
|
|
/**
|
|
* Parses a date string in one of the supported {@link #DATE_PATTERNS}.
|
|
* <p>
|
|
* Received date header values must be in one of the following formats:
|
|
* Sun, 06 Nov 1994 08:49:37 GMT ; RFC 822, updated by RFC 1123
|
|
* Sunday, 06-Nov-94 08:49:37 GMT ; RFC 850, obsoleted by RFC 1036
|
|
* Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format
|
|
*
|
|
* @param time a string representation of a time value
|
|
* @return the parsed date value
|
|
* @throws IllegalArgumentException if the given string does not contain
|
|
* a valid date format in any of the supported formats
|
|
*/
|
|
public static Date parseDate(String time) {
|
|
for (String pattern : DATE_PATTERNS) {
|
|
try {
|
|
SimpleDateFormat df = new SimpleDateFormat(pattern, Locale.US);
|
|
df.setLenient(false);
|
|
df.setTimeZone(GMT);
|
|
return df.parse(time);
|
|
} catch (ParseException ignore) {}
|
|
}
|
|
throw new IllegalArgumentException("invalid date format: " + time);
|
|
}
|
|
|
|
/**
|
|
* Formats the given time value as a string in RFC 1123 format.
|
|
*
|
|
* @param time the time in milliseconds since January 1, 1970, 00:00:00 GMT
|
|
* @return the given time value as a string in RFC 1123 format
|
|
*/
|
|
public static String formatDate(long time) {
|
|
// this implementation performs far better than SimpleDateFormat instances, and even
|
|
// quite better than ThreadLocal SDFs - the server's CPU-bound benchmark gains over 20%!
|
|
if (time < -62167392000000L || time > 253402300799999L)
|
|
throw new IllegalArgumentException("year out of range (0001-9999): " + time);
|
|
char[] s = "DAY, 00 MON 0000 00:00:00 GMT".toCharArray(); // copy the format template
|
|
Calendar cal = new GregorianCalendar(GMT, Locale.US);
|
|
cal.setTimeInMillis(time);
|
|
System.arraycopy(DAYS, 4 * (cal.get(Calendar.DAY_OF_WEEK) - 1), s, 0, 3);
|
|
System.arraycopy(MONTHS, 4 * cal.get(Calendar.MONTH), s, 8, 3);
|
|
int n = cal.get(Calendar.DATE); s[5] += n / 10; s[6] += n % 10;
|
|
n = cal.get(Calendar.YEAR); s[12] += n / 1000; s[13] += n / 100 % 10;
|
|
s[14] += n / 10 % 10; s[15] += n % 10;
|
|
n = cal.get(Calendar.HOUR_OF_DAY); s[17] += n / 10; s[18] += n % 10;
|
|
n = cal.get(Calendar.MINUTE); s[20] += n / 10; s[21] += n % 10;
|
|
n = cal.get(Calendar.SECOND); s[23] += n / 10; s[24] += n % 10;
|
|
return new String(s);
|
|
}
|
|
|
|
/**
|
|
* Splits the given element list string (comma-separated header value)
|
|
* into its constituent non-empty trimmed elements.
|
|
* (RFC2616#2.1: element lists are delimited by a comma and optional LWS,
|
|
* and empty elements are ignored).
|
|
*
|
|
* @param list the element list string
|
|
* @param lower specifies whether the list elements should be lower-cased
|
|
* @return the non-empty elements in the list, or an empty array
|
|
*/
|
|
public static String[] splitElements(String list, boolean lower) {
|
|
return split(lower && list != null ? list.toLowerCase(Locale.US) : list, ",", -1);
|
|
}
|
|
|
|
/**
|
|
* Splits the given string into its constituent non-empty trimmed elements,
|
|
* which are delimited by any of the given delimiter characters.
|
|
* This is a more direct and efficient implementation than using a regex
|
|
* (e.g. String.split()), trimming the elements and removing empty ones.
|
|
*
|
|
* @param str the string to split
|
|
* @param delimiters the characters used as the delimiters between elements
|
|
* @param limit if positive, limits the returned array size (remaining of str in last element)
|
|
* @return the non-empty elements in the string, or an empty array
|
|
*/
|
|
public static String[] split(String str, String delimiters, int limit) {
|
|
if (str == null)
|
|
return new String[0];
|
|
Collection<String> elements = new ArrayList<>();
|
|
int len = str.length();
|
|
int start = 0;
|
|
int end;
|
|
while (start < len) {
|
|
for (end = --limit == 0 ? len : start;
|
|
end < len && delimiters.indexOf(str.charAt(end)) < 0; end++);
|
|
String element = str.substring(start, end).trim();
|
|
if (element.length() > 0)
|
|
elements.add(element);
|
|
start = end + 1;
|
|
}
|
|
return elements.toArray(String[]::new);
|
|
}
|
|
|
|
/**
|
|
* Returns a string constructed by joining the string representations of the
|
|
* iterated objects (in order), with the delimiter inserted between them.
|
|
*
|
|
* @param delim the delimiter that is inserted between the joined strings
|
|
* @param items the items whose string representations are joined
|
|
* @param <T> the item type
|
|
* @return the joined string
|
|
*/
|
|
public static <T> String join(String delim, Iterable<T> items) {
|
|
StringBuilder sb = new StringBuilder();
|
|
for (Iterator<T> it = items.iterator(); it.hasNext(); )
|
|
sb.append(it.next()).append(it.hasNext() ? delim : "");
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* Returns the parent of the given path.
|
|
*
|
|
* @param path the path whose parent is returned (must start with '/')
|
|
* @return the parent of the given path (excluding trailing slash),
|
|
* or null if given path is the root path
|
|
*/
|
|
public static String getParentPath(String path) {
|
|
path = trimRight(path, '/'); // remove trailing slash
|
|
int slash = path.lastIndexOf('/');
|
|
return slash < 0 ? null : path.substring(0, slash);
|
|
}
|
|
|
|
/**
|
|
* Returns the given string with all occurrences of the given character
|
|
* removed from its right side.
|
|
*
|
|
* @param s the string to trim
|
|
* @param c the character to remove
|
|
* @return the trimmed string
|
|
*/
|
|
public static String trimRight(String s, char c) {
|
|
int len = s.length() - 1;
|
|
int end;
|
|
for (end = len; end >= 0 && s.charAt(end) == c; end--);
|
|
return end == len ? s : s.substring(0, end + 1);
|
|
}
|
|
|
|
/**
|
|
* Returns the given string with all occurrences of the given character
|
|
* removed from its left side.
|
|
*
|
|
* @param s the string to trim
|
|
* @param c the character to remove
|
|
* @return the trimmed string
|
|
*/
|
|
public static String trimLeft(String s, char c) {
|
|
int len = s.length();
|
|
int start;
|
|
for (start = 0; start < len && s.charAt(start) == c; start++);
|
|
return start == 0 ? s : s.substring(start);
|
|
}
|
|
|
|
/**
|
|
* Trims duplicate consecutive occurrences of the given character within the
|
|
* given string, replacing them with a single instance of the character.
|
|
*
|
|
* @param s the string to trim
|
|
* @param c the character to trim
|
|
* @return the given string with duplicate consecutive occurrences of c
|
|
* replaced by a single instance of c
|
|
*/
|
|
public static String trimDuplicates(String s, char c) {
|
|
int start = 0;
|
|
while ((start = s.indexOf(c, start) + 1) > 0) {
|
|
int end;
|
|
for (end = start; end < s.length() && s.charAt(end) == c; end++);
|
|
if (end > start)
|
|
s = s.substring(0, start) + s.substring(end);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Returns a human-friendly string approximating the given data size,
|
|
* e.g. "316", "1.8K", "324M", etc.
|
|
*
|
|
* @param size the size to display
|
|
* @return a human-friendly string approximating the given data size
|
|
*/
|
|
public static String toSizeApproxString(long size) {
|
|
final char[] units = { ' ', 'K', 'M', 'G', 'T', 'P', 'E' };
|
|
int u;
|
|
double s;
|
|
for (u = 0, s = size; s >= 1000; u++, s /= 1024);
|
|
return String.format(s < 10 ? "%.1f%c" : "%.0f%c", s, units[u]);
|
|
}
|
|
|
|
/**
|
|
* Returns an HTML-escaped version of the given string for safe display
|
|
* within a web page. The characters '&', '>' and '<' must always
|
|
* be escaped, and single and double quotes must be escaped within
|
|
* attribute values; this method escapes them always. This method can
|
|
* be used for generating both HTML and XHTML valid content.
|
|
*
|
|
* @param s the string to escape
|
|
* @return the escaped string
|
|
* @see <a href="http://www.w3.org/International/questions/qa-escapes">The W3C FAQ</a>
|
|
*/
|
|
public static String escapeHTML(String s) {
|
|
int len = s.length();
|
|
StringBuilder sb = new StringBuilder(len + 30);
|
|
int start = 0;
|
|
for (int i = 0; i < len; i++) {
|
|
String ref = switch (s.charAt(i)) {
|
|
case '&' -> "&";
|
|
case '>' -> ">";
|
|
case '<' -> "<";
|
|
case '"' -> """;
|
|
case '\'' -> "'";
|
|
default -> null;
|
|
};
|
|
if (ref != null) {
|
|
sb.append(s, start, i).append(ref);
|
|
start = i + 1;
|
|
}
|
|
}
|
|
return start == 0 ? s : sb.append(s.substring(start)).toString();
|
|
}
|
|
|
|
/**
|
|
* Converts strings to bytes by casting the chars to bytes.
|
|
* This is a fast way to encode a string as ISO-8859-1/US-ASCII bytes.
|
|
* If multiple strings are provided, their bytes are concatenated.
|
|
*
|
|
* @param strings the strings to convert (containing only ISO-8859-1 chars)
|
|
* @return the byte array
|
|
*/
|
|
public static byte[] getBytes(String... strings) {
|
|
int n = 0;
|
|
for (String s : strings)
|
|
n += s.length();
|
|
byte[] b = new byte[n];
|
|
n = 0;
|
|
for (String s : strings)
|
|
for (int i = 0, len = s.length(); i < len; i++)
|
|
b[n++] = (byte)s.charAt(i);
|
|
return b;
|
|
}
|
|
|
|
/**
|
|
* Transfers data from an input stream to an output stream.
|
|
*
|
|
* @param in the input stream to transfer from
|
|
* @param out the output stream to transfer to (or null to discard output)
|
|
* @param len the number of bytes to transfer. If negative, the entire
|
|
* contents of the input stream are transferred.
|
|
* @throws IOException if an IO error occurs or the input stream ends
|
|
* before the requested number of bytes have been read
|
|
*/
|
|
public static void transfer(InputStream in, OutputStream out, long len) throws IOException {
|
|
if (len == 0 || out == null && len < 0 && in.read() < 0)
|
|
return; // small optimization - avoid buffer creation
|
|
byte[] buf = new byte[4096];
|
|
while (len != 0) {
|
|
int count = len < 0 || buf.length < len ? buf.length : (int)len;
|
|
count = in.read(buf, 0, count);
|
|
if (count < 0) {
|
|
if (len > 0)
|
|
throw new IOException("unexpected end of stream");
|
|
break;
|
|
}
|
|
if (out != null)
|
|
out.write(buf, 0, count);
|
|
len -= len > 0 ? count : 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads the token starting at the current stream position and ending at
|
|
* the first occurrence of the given delimiter byte, in the given encoding.
|
|
* If LF is specified as the delimiter, a CRLF pair is also treated as one.
|
|
*
|
|
* @param in the stream from which the token is read
|
|
* @param delim the byte value which marks the end of the token,
|
|
* or -1 if the token ends at the end of the stream
|
|
* @param enc a character-encoding name
|
|
* @param maxLength the maximum length (in bytes) to read
|
|
* @return the read token, excluding the delimiter
|
|
* @throws UnsupportedEncodingException if the encoding is not supported
|
|
* @throws EOFException if the stream end is reached before a delimiter is found
|
|
* @throws IOException if an IO error occurs, or the maximum length
|
|
* is reached before the token end is reached
|
|
*/
|
|
public static String readToken(InputStream in, int delim,
|
|
String enc, int maxLength) throws IOException {
|
|
// note: we avoid using a ByteArrayOutputStream here because it
|
|
// suffers the overhead of synchronization for each byte written
|
|
int b;
|
|
int len = 0; // buffer length
|
|
int count = 0; // number of read bytes
|
|
byte[] buf = null; // optimization - lazy allocation only if necessary
|
|
while ((b = in.read()) != -1 && b != delim) {
|
|
if (count == len) { // expand buffer
|
|
if (count == maxLength)
|
|
throw new IOException("token too large (" + count + ")");
|
|
len = len > 0 ? 2 * len : 256; // start small, double each expansion
|
|
len = Math.min(maxLength, len);
|
|
byte[] expanded = new byte[len];
|
|
if (buf != null)
|
|
System.arraycopy(buf, 0, expanded, 0, count);
|
|
buf = expanded;
|
|
}
|
|
buf[count++] = (byte)b;
|
|
}
|
|
if (b < 0 && delim != -1)
|
|
throw new EOFException("unexpected end of stream");
|
|
if (delim == '\n' && count > 0 && buf[count - 1] == '\r')
|
|
count--;
|
|
return count > 0 ? new String(buf, 0, count, enc) : "";
|
|
}
|
|
|
|
/**
|
|
* Reads the ISO-8859-1 encoded string starting at the current stream
|
|
* position and ending at the first occurrence of the LF character.
|
|
*
|
|
* @param in the stream from which the line is read
|
|
* @return the read string, excluding the terminating LF character
|
|
* and (if exists) the CR character immediately preceding it
|
|
* @throws EOFException if the stream end is reached before an LF character is found
|
|
* @throws IOException if an IO error occurs, or the line is longer than 8192 bytes
|
|
* @see #readToken(InputStream, int, String, int)
|
|
*/
|
|
public static String readLine(InputStream in) throws IOException {
|
|
return readToken(in, '\n', "ISO8859_1", 8192);
|
|
}
|
|
|
|
/**
|
|
* Reads headers from the given stream. Headers are read according to the
|
|
* RFC, including folded headers, element lists, and multiple headers
|
|
* (which are concatenated into a single element list header).
|
|
* Leading and trailing whitespace is removed.
|
|
*
|
|
* @param in the stream from which the headers are read
|
|
* @return the read headers (possibly empty, if none exist)
|
|
* @throws IOException if an IO error occurs or the headers are malformed
|
|
* or there are more than 100 header lines
|
|
*/
|
|
public static Headers readHeaders(InputStream in) throws IOException {
|
|
Headers headers = new Headers();
|
|
String line;
|
|
String prevLine = "";
|
|
int count = 0;
|
|
while ((line = readLine(in)).length() > 0) {
|
|
int start; // start of line data (after whitespace)
|
|
for (start = 0; start < line.length() &&
|
|
Character.isWhitespace(line.charAt(start)); start++);
|
|
if (start > 0) // unfold header continuation line
|
|
line = prevLine + ' ' + line.substring(start);
|
|
int separator = line.indexOf(':');
|
|
if (separator < 0)
|
|
throw new IOException("invalid header: \"" + line + "\"");
|
|
String name = line.substring(0, separator);
|
|
String value = line.substring(separator + 1).trim(); // ignore LWS
|
|
Header replaced = headers.replace(name, value);
|
|
// concatenate repeated headers (distinguishing repeated from folded)
|
|
if (replaced != null && start == 0) {
|
|
value = replaced.getValue() + ", " + value;
|
|
line = name + ": " + value;
|
|
headers.replace(name, value);
|
|
}
|
|
prevLine = line;
|
|
if (++count > 100)
|
|
throw new IOException("too many header lines");
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Matches the given ETag value against the given ETags. A match is found
|
|
* if the given ETag is not null, and either the ETags contain a "*" value,
|
|
* or one of them is identical to the given ETag. If strong comparison is
|
|
* used, tags beginning with the weak ETag prefix "W/" never match.
|
|
* See RFC2616#3.11, RFC2616#13.3.3.
|
|
*
|
|
* @param strong if true, strong comparison is used, otherwise weak
|
|
* comparison is used
|
|
* @param etags the ETags to match against
|
|
* @param etag the ETag to match
|
|
* @return true if the ETag is matched, false otherwise
|
|
*/
|
|
public static boolean match(boolean strong, String[] etags, String etag) {
|
|
if (etag == null || strong && etag.startsWith("W/"))
|
|
return false;
|
|
for (String e : etags)
|
|
if (e.equals("*") || (e.equals(etag) && !(strong && (e.startsWith("W/")))))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Calculates the appropriate response status for the given request and
|
|
* its resource's last-modified time and ETag, based on the conditional
|
|
* headers present in the request.
|
|
*
|
|
* @param req the request
|
|
* @param lastModified the resource's last modified time
|
|
* @param etag the resource's ETag
|
|
* @return the appropriate response status for the request
|
|
*/
|
|
public static int getConditionalStatus(Request req, long lastModified, String etag) {
|
|
Headers headers = req.getHeaders();
|
|
// If-Match
|
|
String header = headers.get("If-Match");
|
|
if (header != null && !match(true, splitElements(header, false), etag))
|
|
return 412;
|
|
// If-Unmodified-Since
|
|
Date date = headers.getDate("If-Unmodified-Since");
|
|
if (date != null && lastModified > date.getTime())
|
|
return 412;
|
|
// If-Modified-Since
|
|
int status = 200;
|
|
boolean force = false;
|
|
date = headers.getDate("If-Modified-Since");
|
|
if (date != null && date.getTime() <= System.currentTimeMillis()) {
|
|
if (lastModified > date.getTime())
|
|
force = true;
|
|
else
|
|
status = 304;
|
|
}
|
|
// If-None-Match
|
|
header = headers.get("If-None-Match");
|
|
if (header != null) {
|
|
if (match(false, splitElements(header, false), etag)) // RFC7232#3.2: use weak matching
|
|
status = req.getMethod().equals("GET")
|
|
|| req.getMethod().equals("HEAD") ? 304 : 412;
|
|
else
|
|
force = true;
|
|
}
|
|
return force ? 200 : status;
|
|
}
|
|
|
|
/**
|
|
* Serves a context's contents from a file based resource.
|
|
* <p>
|
|
* The file is located by stripping the given context prefix from
|
|
* the request's path, and appending the result to the given base directory.
|
|
* <p>
|
|
* Missing, forbidden and otherwise invalid files return the appropriate
|
|
* error response. Directories are served as an HTML index page if the
|
|
* virtual host allows one, or a forbidden error otherwise. Files are
|
|
* sent with their corresponding content types, and handle conditional
|
|
* and partial retrievals according to the RFC.
|
|
*
|
|
* @param base the base directory to which the context is mapped
|
|
* @param context the context which is mapped to the base directory
|
|
* @param req the request
|
|
* @param resp the response into which the content is written
|
|
* @return the HTTP status code to return, or 0 if a response was sent
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public static int serveFile(File base, String context,
|
|
Request req, Response resp) throws IOException {
|
|
String relativePath = req.getPath().substring(context.length());
|
|
File file = new File(base, relativePath).getCanonicalFile();
|
|
if (!file.exists() || file.isHidden() || file.getName().startsWith(".")) {
|
|
return 404;
|
|
} else if (!file.canRead() || !file.getPath().startsWith(base.getPath())) { // validate
|
|
return 403;
|
|
} else if (file.isDirectory()) {
|
|
if (relativePath.endsWith("/")) {
|
|
if (!req.getVirtualHost().isAllowGeneratedIndex())
|
|
return 403;
|
|
resp.send(200, createIndex(file, req.getPath()));
|
|
} else { // redirect to the normalized directory URL ending with '/'
|
|
resp.redirect(req.getBaseURL() + req.getPath() + "/", true);
|
|
}
|
|
} else if (relativePath.endsWith("/")) {
|
|
return 404; // non-directory ending with slash (File constructor removed it)
|
|
} else {
|
|
serveFileContent(file, req, resp);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Serves the contents of a file, with its corresponding content type,
|
|
* last modification time, etc. conditional and partial retrievals are
|
|
* handled according to the RFC.
|
|
*
|
|
* @param file the existing and readable file whose contents are served
|
|
* @param req the request
|
|
* @param resp the response into which the content is written
|
|
* @throws IOException if an error occurs
|
|
*/
|
|
public static void serveFileContent(File file, Request req, Response resp) throws IOException {
|
|
long len = file.length();
|
|
long lastModified = file.lastModified();
|
|
String etag = "W/\"" + lastModified + "\""; // a weak tag based on date
|
|
int status = 200;
|
|
// handle range or conditional request
|
|
long[] range = req.getRange(len);
|
|
if (range == null || len == 0) {
|
|
status = getConditionalStatus(req, lastModified, etag);
|
|
} else {
|
|
String ifRange = req.getHeaders().get("If-Range");
|
|
if (ifRange == null) {
|
|
if (range[0] >= len)
|
|
status = 416; // unsatisfiable range
|
|
else
|
|
status = getConditionalStatus(req, lastModified, etag);
|
|
} else if (range[0] >= len) {
|
|
// RFC2616#14.16, 10.4.17: invalid If-Range gets everything
|
|
range = null;
|
|
} else { // send either range or everything
|
|
if (!ifRange.startsWith("\"") && !ifRange.startsWith("W/")) {
|
|
Date date = req.getHeaders().getDate("If-Range");
|
|
if (date != null && lastModified > date.getTime())
|
|
range = null; // modified - send everything
|
|
} else if (!ifRange.equals(etag)) {
|
|
range = null; // modified - send everything
|
|
}
|
|
}
|
|
}
|
|
// send the response
|
|
Headers respHeaders = resp.getHeaders();
|
|
switch (status) {
|
|
case 304 -> { // no other headers or body allowed
|
|
respHeaders.add("ETag", etag);
|
|
respHeaders.add("Vary", "Accept-Encoding");
|
|
respHeaders.add("Last-Modified", formatDate(lastModified));
|
|
resp.sendHeaders(304);
|
|
}
|
|
case 412 -> resp.sendHeaders(412);
|
|
case 416 -> {
|
|
respHeaders.add("Content-Range", "bytes */" + len);
|
|
resp.sendHeaders(416);
|
|
}
|
|
case 200 -> {
|
|
// send OK response
|
|
resp.sendHeaders(200, len, lastModified, etag,
|
|
getContentType(file.getName(), "application/octet-stream"), range);
|
|
// send body
|
|
try (InputStream in = new FileInputStream(file)) {
|
|
resp.sendBody(in, len, range);
|
|
}
|
|
}
|
|
default -> resp.sendHeaders(500); // should never happen
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serves the contents of a directory as an HTML file index.
|
|
*
|
|
* @param dir the existing and readable directory whose contents are served
|
|
* @param path the displayed base path corresponding to dir
|
|
* @return an HTML string containing the file index for the directory
|
|
*/
|
|
public static String createIndex(File dir, String path) {
|
|
if (!path.endsWith("/"))
|
|
path += "/";
|
|
// calculate name column width
|
|
int w = 21; // minimum width
|
|
for (String name : dir.list())
|
|
if (name.length() > w)
|
|
w = name.length();
|
|
w += 2; // with room for added slash and space
|
|
// note: we use apache's format, for consistent user experience
|
|
Formatter f = new Formatter(Locale.US);
|
|
f.format("<!DOCTYPE html>%n" +
|
|
"<html><head><title>Index of %s</title></head>%n" +
|
|
"<body><h1>Index of %s</h1>%n" +
|
|
"<pre> Name%" + (w - 5) + "s Last modified Size<hr>",
|
|
path, path, "");
|
|
if (path.length() > 1) // add parent link if not root path
|
|
f.format(" <a href=\"%s/\">Parent Directory</a>%"
|
|
+ (w + 5) + "s-%n", getParentPath(path), "");
|
|
for (File file : dir.listFiles()) {
|
|
try {
|
|
String name = file.getName() + (file.isDirectory() ? "/" : "");
|
|
String size = file.isDirectory() ? "- " : toSizeApproxString(file.length());
|
|
// properly url-encode the link
|
|
String link = new URI(null, path + name, null).toASCIIString();
|
|
if (!file.isHidden() && !name.startsWith("."))
|
|
f.format(" <a href=\"%s\">%s</a>%-" + (w - name.length()) +
|
|
"s‎%td-%<tb-%<tY %<tR%6s%n",
|
|
link, name, "", file.lastModified(), size);
|
|
} catch (URISyntaxException ignore) {}
|
|
}
|
|
f.format("</pre></body></html>");
|
|
return f.toString();
|
|
}
|
|
|
|
/**
|
|
* Starts a stand-alone HTTP server, serving files from disk.
|
|
*
|
|
* @param args the command line arguments
|
|
*/
|
|
public static void main(String[] args) {
|
|
try {
|
|
if (args.length == 0) {
|
|
System.err.printf("Usage: java [-options] %s <directory> [port]%n" +
|
|
"To enable SSL: specify options -Djavax.net.ssl.keyStore, " +
|
|
"-Djavax.net.ssl.keyStorePassword, etc.%n", JLHTTPServer.class.getName());
|
|
return;
|
|
}
|
|
File dir = new File(args[0]);
|
|
if (!dir.canRead())
|
|
throw new FileNotFoundException(dir.getAbsolutePath());
|
|
int port = args.length < 2 ? 80 : (int)parseULong(args[1], 10);
|
|
// set up server
|
|
for (File f : Arrays.asList(new File("/etc/mime.types"), new File(dir, ".mime.types")))
|
|
if (f.exists())
|
|
addContentTypes(new FileInputStream(f));
|
|
JLHTTPServer server = new JLHTTPServer(port);
|
|
if (System.getProperty("javax.net.ssl.keyStore") != null) // enable SSL if configured
|
|
server.setServerSocketFactory(SSLServerSocketFactory.getDefault());
|
|
VirtualHost host = server.getVirtualHost(null); // default host
|
|
host.setAllowGeneratedIndex(true); // with directory index pages
|
|
host.addContext("/", new FileContextHandler(dir));
|
|
host.addContext("/api/time", (req, resp) -> {
|
|
long now = System.currentTimeMillis();
|
|
resp.getHeaders().add("Content-Type", "text/plain");
|
|
resp.send(200, String.format("%tF %<tT", now));
|
|
return 0;
|
|
});
|
|
server.start();
|
|
System.out.println("HTTPServer is listening on port " + port);
|
|
} catch (Exception e) {
|
|
System.err.println("error: " + e);
|
|
}
|
|
}
|
|
}
|