Skip to content

Instantly share code, notes, and snippets.

@skanga
Last active June 1, 2018 01:22
Show Gist options
  • Save skanga/5b99d5d50ac3c8a718549847e5ea6080 to your computer and use it in GitHub Desktop.
Save skanga/5b99d5d50ac3c8a718549847e5ea6080 to your computer and use it in GitHub Desktop.
Feature rich web server in around 500 lines using pure JDK classes only. Features include file server, dir listing, basic auth, https (self-signed or let's encrypt), file upload, multithreading, access logging, etc.
import com.sun.net.httpserver.*;
import javax.net.ssl.*;
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.security.*;
import java.security.cert.*;
import java.text.*;
import java.util.*;
import java.util.concurrent.*;
import static java.nio.file.Files.probeContentType;
public class HttpServer
{
private final static int MAX_UPLOAD_FILE_SIZE = 1000 * 1024 * 10;
// The maximum number of incoming TCP connections which will be queued. Further connections may be rejected.
// This is a compromise between efficient TCP resource usage (set too high) & throughput of incoming requests (set too low).
private final static int BACKLOG = 100;
// When a new request is submitted and fewer than CORE_POOL_SIZE threads are running, a new thread is created to handle the request,
// even if other worker threads are idle. If there are more than CORE_POOL_SIZE but less than MAX_POOL_SIZE threads running,
// a new thread will be created only if the queue is full.
private final static int CORE_POOL_SIZE = 4;
private final static int MAX_POOL_SIZE = 8;
// After pool has MAX_POOL_SIZE threads, idle threads will be terminated if they have been idle for more than the KEEP_ALIVE_TIME in seconds.
private final static int KEEP_ALIVE_TIME = 30;
// After thread pool at CORE_POOL_SIZE upto MAX_BLOCKING_QUEUE more requests will be queued up before execution
private final static int MAX_BLOCKING_QUEUE = 100;
private final static String ACCESS_LOG = System.getProperty ("user.dir") + File.separatorChar + "access.log";
private final static String INDEX_CSS = readFile (System.getProperty ("user.dir") + File.separatorChar + "index.css");
private final static String UPLOAD_JS = readFile (System.getProperty ("user.dir") + File.separatorChar + "upload.js");
private final static String UPLOAD_CSS = readFile (System.getProperty ("user.dir") + File.separatorChar + "upload.css");
private final static String UPLOAD_HTML = readFile (System.getProperty ("user.dir") + File.separatorChar + "upload.html");
public static void main (String[] args) throws Exception
{
HttpServer httpServer;
String protocolUsed = "http";
HashMap <String, String> argPairs = parseCommandLine (args); //System.out.println (argPairs);
int listenPort = Integer.parseInt (getArg (argPairs, "l", "listen-port", "80"));
String docRoot = getArg (argPairs, "d", "document-root", System.getProperty ("user.dir"));
String authRealm = getArg (argPairs, "r", "auth-realm", null);
String authUser = getArg (argPairs, "u", "auth-user", null);
String authPass = getArg (argPairs, "p", "auth-pass", null);
String keyFile = getArg (argPairs, "f", "keystore-file", null);
String keyPass = getArg (argPairs, "k", "keystore-pass", null);
String uploadUri = getArg (argPairs, "i", "upload-uri", null);
String uploadDir = getArg (argPairs, "o", "upload-dir", null);
String showHelp = getArg (argPairs, "h", "help", null);
if (showHelp != null) showHelp ();
if (keyFile != null && keyPass != null) // If both are present then we need to setup an https configuraion
{
HttpsServer httpsServer = HttpsServer.create (new InetSocketAddress (listenPort), BACKLOG);
protocolUsed = httpsConfig (keyFile, keyPass, httpsServer);
httpServer = httpsServer;
}
else
{
httpServer = HttpServer.create (new InetSocketAddress (listenPort), BACKLOG);
}
HttpContext httpContext = httpServer.createContext ("/", new StaticFileServer (docRoot, uploadDir, uploadUri));
if (authRealm != null && authUser != null && authPass != null) // If all 3 are present then we need to setup basic auth
httpContext.setAuthenticator (getAuthenticator (authRealm, authUser, authPass));
httpServer.setExecutor (new ThreadPoolExecutor (CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue <> (MAX_BLOCKING_QUEUE)));
httpServer.start ();
String hostName = "localhost";
String ipAddr = "127.0.0.1";
try
{
hostName = InetAddress.getLocalHost ().getHostName ();
ipAddr = InetAddress.getLocalHost ().getHostAddress ();
}
catch (UnknownHostException e){}
System.out.println ("Ready at the following URLs:");
System.out.println (protocolUsed + "://" + hostName + ":" + listenPort + "/");
System.out.println (protocolUsed + "://" + ipAddr + ":" + listenPort + "/");
System.out.println ("Serving files from \"" + docRoot + "\"");
System.out.println ("Hit Enter to stop.\n");
try { System.in.read (); } catch (Throwable t) {}
System.exit (-1);
}
// Configure https using the jks key file provided
private static String httpsConfig (String keyFile, String keyPass, HttpsServer httpServer)
throws NoSuchAlgorithmException, KeyStoreException, IOException, CertificateException, UnrecoverableKeyException, KeyManagementException
{
System.out.println ("Configuring HTTPS from key file: " + keyFile);
SSLContext sslContext = getSslContext (keyFile, keyPass);
HttpsConfigurator httpsConfigurator = new HttpsConfigurator (sslContext);
httpServer.setHttpsConfigurator (httpsConfigurator);
final SSLEngine sslEngine = sslContext.createSSLEngine ();
httpServer.setHttpsConfigurator (new HttpsConfigurator (sslContext)
{
public void configure (HttpsParameters httpsParameters)
{
String[] CIPHER_SUITES = new String[] { "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384"};
httpsParameters.setCipherSuites (CIPHER_SUITES); // or httpsParameters.setCipherSuites (sslEngine.getEnabledCipherSuites ());
httpsParameters.setProtocols (sslEngine.getEnabledProtocols ());
}
});
return "https";
}
// Initialize and return the SSL context
private static SSLContext getSslContext (String keystoreFile, String keystorePass) throws NoSuchAlgorithmException, KeyStoreException, IOException, CertificateException, UnrecoverableKeyException, KeyManagementException
{
SSLContext sslContext = SSLContext.getInstance ("TLS"); // or SSLContext.getInstance ("SSLv3");
KeyManagerFactory kmFactory = KeyManagerFactory.getInstance (KeyManagerFactory.getDefaultAlgorithm ()); // or KeyManagerFactory.getInstance ("SunX509");
KeyStore keyStore = KeyStore.getInstance (KeyStore.getDefaultType ()); // or KeyStore.getInstance ("JKS");
keyStore.load (new FileInputStream (keystoreFile), keystorePass.toCharArray ()); // Load the JKS file
kmFactory.init (keyStore, keystorePass.toCharArray ()); // init the key store, with the password
TrustManagerFactory tmFactory = TrustManagerFactory.getInstance (TrustManagerFactory.getDefaultAlgorithm ()); // or TrustManagerFactory.getInstance ("SunX509");
tmFactory.init (keyStore); // Reference the same key store as the KeyManager
sslContext.init (kmFactory.getKeyManagers (), tmFactory.getTrustManagers (), new SecureRandom ());
return sslContext;
}
// Validate the username and password when basic authentication is in use
private static BasicAuthenticator getAuthenticator (String authRealm, String authUser, String authPass)
{
System.out.println ("Configuring Basic Auth for user: " + authUser + " in realm: " + authRealm);
return new BasicAuthenticator (authRealm)
{
@Override
public boolean checkCredentials (String userName, String passWord)
{
return userName.equals (authUser) && passWord.equals (authPass);
}
};
}
// Server for files & folders in the document root dir
static class StaticFileServer implements HttpHandler
{
private String documentRoot, uploadDir, uploadUri;
StaticFileServer (String documentRoot, String uploadDir, String uploadUri)
{
this.documentRoot = documentRoot;
if (uploadDir != null && !uploadDir.endsWith ("/")) uploadDir = uploadDir + File.separator;
if (uploadUri != null && !uploadUri.startsWith ("/")) uploadUri = "/" + uploadUri;
this.uploadDir = uploadDir;
this.uploadUri = uploadUri;
}
@Override
public void handle (HttpExchange httpExchange) throws IOException
{
try
{
int respSize;
Path rootPath = FileSystems.getDefault ().getPath (documentRoot);
Path currPath = FileSystems.getDefault ().getPath (documentRoot, httpExchange.getRequestURI ().getPath ());
OutputStream respStream = httpExchange.getResponseBody ();
// Add HSTS headers to all responses
httpExchange.getResponseHeaders ().add ("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); // max-age 1 yr
if (uploadDir != null && uploadUri != null && httpExchange.getRequestURI ().toString ().equalsIgnoreCase (uploadUri))
respSize = serveUpload (httpExchange, respStream, uploadDir, uploadUri);
else if (Files.isRegularFile (currPath))
respSize = serveFile (httpExchange, currPath, respStream);
else if (Files.isDirectory (currPath))
respSize = serveDir (httpExchange, currPath, rootPath, respStream);
else
respSize = serveError (httpExchange, respStream);
writeAccessLog (httpExchange, respSize);
respStream.flush ();
respStream.close ();
}
catch (Throwable e)
{
System.out.println ("WARNING: Exception while handling HTTP request: " + httpExchange.getRequestURI ());
e.printStackTrace ();
}
}
}
// Log the current hit to the access log in the standard access log format
private static void writeAccessLog (HttpExchange httpExchange, int respSize) throws IOException
{
String userName = "-";
if (httpExchange.getPrincipal () != null) userName = httpExchange.getPrincipal ().getUsername ();
String hitMsg = MessageFormat.format ("{0} - {1} [{2,date,dd/MMM/yyyy:HH:mm:ss Z}] \"{3} {4}\" {5} {6}",
httpExchange.getRemoteAddress ().getHostString (),
userName,
new Date (),
httpExchange.getRequestMethod (),
httpExchange.getRequestURI (),
httpExchange.getResponseCode (),
respSize);
writeBytesToFile (hitMsg.getBytes (), ACCESS_LOG, true);
System.out.println (hitMsg); // Print the access log hit
}
// The current request is for an file. Read in that file and return its contents as the response
private static int serveFile (HttpExchange httpExchange, Path filePath, OutputStream respStream) throws IOException
{
byte[] fileArray = Files.readAllBytes (filePath);
Headers respHeaders = httpExchange.getResponseHeaders ();
String mimeType = probeContentType (filePath);
if (mimeType == null) mimeType = "text/plain";
respHeaders.add ("Content-Type", mimeType);
httpExchange.sendResponseHeaders (200, fileArray.length);
respStream.write (fileArray);
return fileArray.length;
}
// The current request is for an object that was not found. Return an error message
private static int serveError (HttpExchange httpExchange, OutputStream respStream) throws IOException
{
String response = "Error 404 File not found.";
setResponseHeaders (httpExchange, "text/plain", 404, response.getBytes ().length);
respStream.write (response.getBytes ());
return response.length ();
}
// Set the http response headers as per the params provided
private static void setResponseHeaders (HttpExchange httpExchange, String contentType, int errorCode, int contentLength) throws IOException
{
Headers respHeaders = httpExchange.getResponseHeaders ();
respHeaders.add ("Content-Type", contentType);
httpExchange.sendResponseHeaders (errorCode, contentLength);
}
// The current request is for a directory. Read the contents of the directory and return the properly formatted response
private static int serveDir (HttpExchange httpExchange, Path filePath, Path rootPath, OutputStream respStream) throws IOException
{
String headerMsg = MessageFormat.format ("<html>\n<head>\n<title>Index of {0}</title>\n{1}\n</head>\n<body>\n<h1>Index of {2}</h1>\n<div class='main'>\n", filePath.toString (), INDEX_CSS, filePath.toString ());
StringBuilder dirList = new StringBuilder (headerMsg);
DirectoryStream <Path> directoryStream = Files.newDirectoryStream (filePath);
for (Path currEntry : directoryStream)
{
String currName = currEntry.getFileName ().toString ();
if (Files.isDirectory (currEntry)) currName = "<b>" + currName + "</b>/";
String fileMsg = MessageFormat.format ("<div class='GridRow'><div class='GridCell'><a href=/{0}>{1}</a></div><div class='GridCell'>{2}</div><div class='GridCell'>{3}</div></div>\n",
rootPath.relativize (currEntry).toString ().replace ('\\', '/'), currName, Files.getLastModifiedTime (currEntry).toInstant (), readableFileSize (Files.size (currEntry)));
dirList.append (fileMsg);
}
dirList.append ("</div></body></html>\n");
setResponseHeaders (httpExchange, "text/html", 200, dirList.toString ().length ());
respStream.write (dirList.toString ().getBytes ());
return dirList.length ();
}
// The current request is for an upload. Generate the upload form for GET requests otherwise save the uploaded files
private static int serveUpload (HttpExchange httpExchange, OutputStream respStream, String uploadDir, String uploadUri) throws IOException
{
if (httpExchange.getRequestMethod ().equalsIgnoreCase ("GET"))
return showUploadForm (httpExchange, respStream, uploadUri);
else
return saveUploadedFiles (httpExchange, respStream, uploadDir);
}
// Display the file upload html page in the browser
private static int showUploadForm (HttpExchange httpExchange, OutputStream respStream, String uploadUri) throws IOException
{
String uploadHtml = MessageFormat.format (UPLOAD_HTML, UPLOAD_CSS, uploadUri, UPLOAD_JS);
setResponseHeaders (httpExchange, "text/html", 200, uploadHtml.getBytes ().length);
respStream.write (uploadHtml.getBytes ());
return uploadHtml.length ();
}
// Save all uploaded files onto the server file system
private static int saveUploadedFiles (HttpExchange httpExchange, OutputStream respStream, String uploadDir) throws IOException
{
String contentType = httpExchange.getRequestHeaders ().getFirst ("Content-type");
int contentLength = Integer.parseInt (httpExchange.getRequestHeaders ().getFirst ("Content-length"));
String partBoundary = extractString (contentType, "boundary=", "");
partBoundary = "\r\n--" + partBoundary;
//String lastBoundary = partBoundary + "--";
if (contentLength > MAX_UPLOAD_FILE_SIZE)
throw new IOException ("ERROR: Each batch of data can not be larger than " + MAX_UPLOAD_FILE_SIZE / (1000 * 1024) + "M");
byte[] bodyBuffer = readStreamToByteArray (httpExchange.getRequestBody (), contentLength);
List <byte[]> fileList = splitByteArray (bodyBuffer, partBoundary.getBytes ());
StringBuilder outHtml = new StringBuilder ();
String htmlHead = MessageFormat.format ("<html><head><title>File Upload</title>{0}</head><body><div>", UPLOAD_CSS);
outHtml.append (htmlHead);
// Process every buffer except last one which should just be the terminating --
for (byte[] currBuffer : fileList)
{
if ((currBuffer.length == 4) && (currBuffer[0] == '-') && (currBuffer[1] == '-'))
break;
String saveMessage = saveCurrentPart (currBuffer, uploadDir);
outHtml.append (saveMessage);
}
outHtml.append ("</div></body></html>\n");
setResponseHeaders (httpExchange, "text/html", 200, outHtml.toString ().length ());
respStream.write (outHtml.toString ().getBytes ());
return outHtml.length ();
}
// Given a single part of a multi-part message - save the part data into a file in the specified dir
private static String saveCurrentPart (byte[] currBuffer, String uploadDir) throws IOException
{
List <byte[]> fileParts = splitByteArray (currBuffer, "\r\n\r\n".getBytes ());
String fileHeaders = new String (fileParts.get (0));
String fieldName = extractString (fileHeaders, "name=\"", "\";");
String fileName = extractString (fileHeaders, "filename=\"", "\"");
writeBytesToFile (fileParts.get (1), uploadDir + fileName, false);
return MessageFormat.format ("<div class='GridRow'>Saved param \"{0}\" to file named \"{1}\"</div><br>\n", fieldName, fileName);
}
// Format the specified file size (in human readable format).
private static String readableFileSize (long fileSize)
{
if (fileSize <= 0) return "0";
final String[] units = new String[] {"B", "KB", "MB", "GB", "TB", "PB", "EB"};
int digitGroups = (int) (Math.log10 (fileSize) / Math.log10 (1024));
return new DecimalFormat ("#,##0.#").format (fileSize / Math.pow (1024, digitGroups)) + " " + units[digitGroups];
}
// Parse all command line args (in various formats) and store them in a hashmap of name/value pairs
private static HashMap <String, String> parseCommandLine (String[] args)
{
HashMap <String, String> argPairs = new HashMap <> ();
String argName, argValue;
int currIndex;
// loop through the list of arguments
for (int i = 0; i < args.length; i++)
{
// look for a key
if (args[i].startsWith ("--"))
{
argName = args[i].substring (2); // this is a argName that starts with a "--"
}
else if (args[i].startsWith ("-"))
{
argName = args[i].substring (1); // this is a argName that start with a "-"
}
else
{
argPairs.put (args[i], null); // this is a argName that starts with nothing as a value
continue; // end this iteration of the loop
}
// look for a value
currIndex = argName.indexOf ('='); // does the argName contain an "=" character?
if (currIndex == -1) // there is no "=" sign so use the next argument as the value
{
if ((i + 1) < args.length) // is there an argValue to use
{
if (args[i + 1].charAt (0) != '-') // yes - but does the argValue look like a argName?
{
argPairs.put (argName, args[i + 1]); // no - so add the argName and argValue
i++; // increment the count so we don't process this argValue again
}
else
{
argPairs.put (argName, ""); // yes - argValue is the next argName. So no argValue - just add the argName with an empty string
}
}
else
{
argPairs.put (argName, ""); // no argValue - so just add the argName with an empty string
}
}
else
{
argValue = argName.substring (currIndex + 1); // yes - extract the value from the argName
argName = argName.substring (0, currIndex); // fix the argName
argPairs.put (argName, argValue); // add the argName and value to the map
}
}
return argPairs;
}
// Read the value of a command line arg. If both long and short names are present we use the long name
private static String getArg (HashMap <String, String> argPairs, String argShortName, String argLongName, String argDefaultValue)
{
if (argPairs.containsKey (argLongName)) return argPairs.get (argLongName);
if (argPairs.containsKey (argShortName)) return argPairs.get (argShortName);
return argDefaultValue;
}
// Display the command line params
private static void showHelp ()
{
System.out.println ("USAGE: java HttpServer <params>");
System.out.println (" The <params> are key/value pairs with = or space as delimiter and prefixed by - or --");
System.out.println ("* Keys have both Long and short forms. If both are provided then only the long one is used.");
System.out.println (" -l, --listen-port The port on which we listen (default: 80)");
System.out.println (" -d, --document-root The root dir from which all files are served (default: current dir)");
System.out.println ("* Enable Basic Auth (all three required to enable basic auth)");
System.out.println (" -r, --auth-realm The name of the realm for basic auth");
System.out.println (" -u, --auth-user The login username");
System.out.println (" -p, --auth-pass The login password");
System.out.println ("* Enable HTTPS (both required to enable HTTPS)");
System.out.println (" -f, --keystore-file The java keystore for self signed or Let's Encrypt certs");
System.out.println (" -k, --keystore-pass The password for the keystore (if any)");
System.out.println ("* Enable File Uploads (both required to enable file uploads)");
System.out.println (" -i, --upload-uri The URI on which to display a file upload form");
System.out.println (" -o, --upload-dir The location where to save uploaded files");
System.exit (-1);
}
// Write the entire contents of a byte array into a file. Both overwrite and append are supported
private static void writeBytesToFile (byte[] fileBytes, String fileName, boolean appendFlag) throws IOException
{
FileOutputStream outStream = new FileOutputStream (fileName, appendFlag);
outStream.write (fileBytes);
outStream.close ();
}
// Read the entire contents of an inputstream into a byte array
private static byte[] readStreamToByteArray (InputStream inputStream, int contentLength) throws IOException
{
byte[] bodyBuffer = new byte[contentLength];
DataInputStream inStream = new DataInputStream (inputStream);
inStream.readFully (bodyBuffer);
inStream.close ();
return bodyBuffer;
}
// Read the entire content of a file into a string
private static String readFile (String filePath)
{
try
{
return new String (Files.readAllBytes (Paths.get (filePath)));
}
catch (IOException e)
{
System.out.println ("WARNING: Unable to read contents of file: " + filePath);
return null;
}
}
// Get text between two strings. Passed limiting strings are not included into result. Return only the first match.
private static String extractString (String inputText, String textFrom, String textTo)
{
int endPos;
int startPos = inputText.indexOf (textFrom) + textFrom.length ();
if (textTo.equals (""))
endPos = inputText.length ();
else
endPos = inputText.indexOf (textTo, startPos);
return inputText.substring (startPos, endPos);
}
// Split a byte array into a list of byte arrays based on the delimiting byte array
private static List <byte[]> splitByteArray (byte[] byteArray, byte[] delimiterArray)
{
List <byte[]> byteArrays = new LinkedList <> ();
if (delimiterArray.length == 0)
return byteArrays;
int beginIndex = 0;
outer:
for (int i = 0; i < byteArray.length - delimiterArray.length + 1; i++)
{
for (int j = 0; j < delimiterArray.length; j++)
{
if (byteArray[i + j] != delimiterArray[j])
{
continue outer;
}
}
// If delimiter is at the beginning then there will not be any data.
if (beginIndex != i)
byteArrays.add (Arrays.copyOfRange (byteArray, beginIndex, i));
beginIndex = i + delimiterArray.length;
}
// delimiter is at the very end with no data following
if (beginIndex != byteArray.length)
byteArrays.add (Arrays.copyOfRange (byteArray, beginIndex, byteArray.length));
return byteArrays;
}
}
<style type="text/css">
BODY {font-family:Tahoma,Arial,sans-serif; color:black;background-color:white;}
H1 {color:white;background-color:#525D76;font-size:22px;}
.GridRow {border-bottom: 1px solid #eee; clear: both;}
.GridCell {float: left; height: 17px; overflow: hidden; padding: 3px 1.8%; width: 20%;}
</style>
<style type="text/css">
BODY {font-family:Tahoma,Arial,sans-serif; color:black;background-color:white;}
.GridRow {border-bottom: 1px solid #eee; clear: both;}
.dropupload { position: relative; width: 600px; background: #EEE; border: 5px solid #525D76; padding: 10px; }
.dropupload legend { color: white; font-size: 2em; background: #525D76; padding: 5px; }
.dropupload small { position: absolute; margin: -20px 0 0 5px }
.dropupload .fileElem { height: 460px; width: 600px; border: none; background: white; }
.dropupload #status { color: blue }
.dropupload #send { position: absolute; right: 10px; bottom: 10px }
</style>
<!DOCTYPE html>
<html>
<head lang="en">
<title>File Uploader</title>
{0} <!-- CSS goes here -->
</head>
<body>
<fieldset class="dropupload">
<legend>File Uploader</legend>
<form method="post" name="upload" id="upload" enctype="multipart/form-data" action="{1}"> <!-- POST URI goes here -->
<input type="file" name="fileElem" id="fileElem" class="fileElem">
<input type="submit" value="Send" id="send">
<br>
<small>Drag and drop a file here</small>
<span id="status"></span>
</form>
</fieldset>
{2} <!-- JS goes here -->
</body>
</html>
<script type="text/javascript">
// Auto send file when input value changes
$("#upload").change(function()
{
$('#status').text('Upload in progress...');
document.getElementById("send").click();
});
// Drag and drop color change
$(document).on('dragover', '#fileElem', function(e)
{
e.preventDefault();
$(this).css('background-color', 'red');
});
$(document).on('dragleave', '#fileElem', function(e)
{
$(this).css('background-color', 'white');
});
$(document).on('drop', '#fileElem', function(e)
{
$('#status').html('Upload in progress...');
$(this).css('background-color', 'blue');
});
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment