diff options
10 files changed, 1906 insertions, 1893 deletions
diff --git a/core/src/main/java/fi/iki/elonen/NanoHTTPD.java b/core/src/main/java/fi/iki/elonen/NanoHTTPD.java index 0705196..06afeb9 100644 --- a/core/src/main/java/fi/iki/elonen/NanoHTTPD.java +++ b/core/src/main/java/fi/iki/elonen/NanoHTTPD.java @@ -56,6 +56,7 @@ import java.net.SocketTimeoutException; import java.net.URLDecoder; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; +import java.security.KeyStore; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; @@ -72,8 +73,12 @@ import java.util.TimeZone; import java.util.logging.Level; import java.util.logging.Logger; -import java.security.KeyStore; -import javax.net.ssl.*; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.TrustManagerFactory; /** * A simple, tiny, nicely embeddable HTTP server in Java @@ -129,499 +134,134 @@ import javax.net.ssl.*; public abstract class NanoHTTPD { /** - * Maximum time to wait on Socket.getInputStream().read() (in milliseconds) - * This is required as the Keep-Alive HTTP connections would otherwise block - * the socket reading thread forever (or as long the browser is open). - */ - public static final int SOCKET_READ_TIMEOUT = 5000; - - /** - * Common MIME type for dynamic content: plain text - */ - public static final String MIME_PLAINTEXT = "text/plain"; - - /** - * Common MIME type for dynamic content: html - */ - public static final String MIME_HTML = "text/html"; - - /** - * Pseudo-Parameter to use to store the actual query string in the - * parameters map for later re-processing. - */ - private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING"; - - /** - * logger to log to. - */ - private static Logger LOG = Logger.getLogger(NanoHTTPD.class.getName()); - - private final String hostname; - - private final int myPort; - - private ServerSocket myServerSocket; - - private Set<Socket> openConnections = new HashSet<Socket>(); - - private SSLServerSocketFactory sslServerSocketFactory; - - private Thread myThread; - - /** * Pluggable strategy for asynchronously executing requests. */ - private AsyncRunner asyncRunner; - - /** - * Pluggable strategy for creating and cleaning up temporary files. - */ - private TempFileManagerFactory tempFileManagerFactory; + public interface AsyncRunner { - /** - * Constructs an HTTP server on given port. - */ - public NanoHTTPD(int port) { - this(null, port); + void exec(Runnable code); } - /** - * Constructs an HTTP server on given hostname and port. - */ - public NanoHTTPD(String hostname, int port) { - this.hostname = hostname; - this.myPort = port; - setTempFileManagerFactory(new DefaultTempFileManagerFactory()); - setAsyncRunner(new DefaultAsyncRunner()); - } + public static class Cookie { - private static final void safeClose(Closeable closeable) { - if (closeable != null) { - try { - closeable.close(); - } catch (IOException e) { - LOG.log(Level.SEVERE, "Could not close", e); - } + public static String getHTTPTime(int days) { + Calendar calendar = Calendar.getInstance(); + SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); + calendar.add(Calendar.DAY_OF_MONTH, days); + return dateFormat.format(calendar.getTime()); } - } - /** - * Creates an SSLSocketFactory for HTTPS. Pass a KeyStore resource with your - * certificate and passphrase - */ - public static SSLServerSocketFactory makeSSLSocketFactory(String keyAndTrustStoreClasspathPath, char[] passphrase) throws IOException { - SSLServerSocketFactory res = null; - try { - KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); - InputStream keystoreStream = NanoHTTPD.class.getResourceAsStream(keyAndTrustStoreClasspathPath); - keystore.load(keystoreStream, passphrase); - TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init(keystore); - KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - keyManagerFactory.init(keystore, passphrase); - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); - res = ctx.getServerSocketFactory(); - } catch (Exception e) { - throw new IOException(e.getMessage()); + private final String n, v, e; + + public Cookie(String name, String value) { + this(name, value, 30); } - return res; - } - /** - * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and a - * loaded KeyManagerFactory. These objects must properly loaded/initialized - * by the caller. - */ - public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManagerFactory loadedKeyFactory) throws IOException { - SSLServerSocketFactory res = null; - try { - TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init(loadedKeyStore); - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(loadedKeyFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); - res = ctx.getServerSocketFactory(); - } catch (Exception e) { - throw new IOException(e.getMessage()); + public Cookie(String name, String value, int numDays) { + this.n = name; + this.v = value; + this.e = getHTTPTime(numDays); } - return res; - } - /** - * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and an - * array of loaded KeyManagers. These objects must properly - * loaded/initialized by the caller. - */ - public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManager[] keyManagers) throws IOException { - SSLServerSocketFactory res = null; - try { - TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init(loadedKeyStore); - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(keyManagers, trustManagerFactory.getTrustManagers(), null); - res = ctx.getServerSocketFactory(); - } catch (Exception e) { - throw new IOException(e.getMessage()); + public Cookie(String name, String value, String expires) { + this.n = name; + this.v = value; + this.e = expires; } - return res; - } - /** - * Call before start() to serve over HTTPS instead of HTTP - */ - public void makeSecure(SSLServerSocketFactory sslServerSocketFactory) { - this.sslServerSocketFactory = sslServerSocketFactory; + public String getHTTPHeader() { + String fmt = "%s=%s; expires=%s"; + return String.format(fmt, this.n, this.v, this.e); + } } /** - * Start the server. + * Provides rudimentary support for cookies. Doesn't support 'path', + * 'secure' nor 'httpOnly'. Feel free to improve it and/or add unsupported + * features. * - * @throws IOException - * if the socket is in use. + * @author LordFokas */ - public void start() throws IOException { - if (sslServerSocketFactory != null) { - SSLServerSocket ss = (SSLServerSocket) sslServerSocketFactory.createServerSocket(); - ss.setNeedClientAuth(false); - myServerSocket = ss; - } else { - myServerSocket = new ServerSocket(); - } - myServerSocket.setReuseAddress(true); - myServerSocket.bind((hostname != null) ? new InetSocketAddress(hostname, myPort) : new InetSocketAddress(myPort)); + public class CookieHandler implements Iterable<String> { - myThread = new Thread(new Runnable() { + private final HashMap<String, String> cookies = new HashMap<String, String>(); - @Override - public void run() { - do { - try { - final Socket finalAccept = myServerSocket.accept(); - registerConnection(finalAccept); - finalAccept.setSoTimeout(SOCKET_READ_TIMEOUT); - final InputStream inputStream = finalAccept.getInputStream(); - asyncRunner.exec(new Runnable() { + private final ArrayList<Cookie> queue = new ArrayList<Cookie>(); - @Override - public void run() { - OutputStream outputStream = null; - try { - outputStream = finalAccept.getOutputStream(); - TempFileManager tempFileManager = tempFileManagerFactory.create(); - HTTPSession session = new HTTPSession(tempFileManager, inputStream, outputStream, finalAccept.getInetAddress()); - while (!finalAccept.isClosed()) { - session.execute(); - } - } catch (Exception e) { - // When the socket is closed by the client, - // we throw our own SocketException - // to break the "keep alive" loop above. If - // the exception was anything other - // than the expected SocketException OR a - // SocketTimeoutException, print the - // stacktrace - if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage())) && !(e instanceof SocketTimeoutException)) { - LOG.log(Level.FINE, "Communication with the client broken", e); - } - } finally { - safeClose(outputStream); - safeClose(inputStream); - safeClose(finalAccept); - unRegisterConnection(finalAccept); - } - } - }); - } catch (IOException e) { - LOG.log(Level.FINE, "Communication with the client broken", e); + public CookieHandler(Map<String, String> httpHeaders) { + String raw = httpHeaders.get("cookie"); + if (raw != null) { + String[] tokens = raw.split(";"); + for (String token : tokens) { + String[] data = token.trim().split("="); + if (data.length == 2) { + this.cookies.put(data[0], data[1]); } - } while (!myServerSocket.isClosed()); - } - }); - myThread.setDaemon(true); - myThread.setName("NanoHttpd Main Listener"); - myThread.start(); - } - - /** - * Stop the server. - */ - public void stop() { - try { - safeClose(myServerSocket); - closeAllConnections(); - if (myThread != null) { - myThread.join(); + } } - } catch (Exception e) { - LOG.log(Level.SEVERE, "Could not stop all connections", e); } - } - /** - * Registers that a new connection has been set up. - * - * @param socket - * the {@link Socket} for the connection. - */ - public synchronized void registerConnection(Socket socket) { - openConnections.add(socket); - } - - /** - * Registers that a connection has been closed - * - * @param socket - * the {@link Socket} for the connection. - */ - public synchronized void unRegisterConnection(Socket socket) { - openConnections.remove(socket); - } - - /** - * Forcibly closes all connections that are open. - */ - public synchronized void closeAllConnections() { - for (Socket socket : openConnections) { - safeClose(socket); + /** + * Set a cookie with an expiration date from a month ago, effectively + * deleting it on the client side. + * + * @param name + * The cookie name. + */ + public void delete(String name) { + set(name, "-delete-", -30); } - } - - public final int getListeningPort() { - return myServerSocket == null ? -1 : myServerSocket.getLocalPort(); - } - - public final boolean wasStarted() { - return myServerSocket != null && myThread != null; - } - public final boolean isAlive() { - return wasStarted() && !myServerSocket.isClosed() && myThread.isAlive(); - } - - /** - * Override this to customize the server. - * <p/> - * <p/> - * (By default, this returns a 404 "Not Found" plain text error response.) - * - * @param uri - * Percent-decoded URI without parameters, for example - * "/index.cgi" - * @param method - * "GET", "POST" etc. - * @param parms - * Parsed, percent decoded parameters from URI and, in case of - * POST, data. - * @param headers - * Header entries, percent decoded - * @return HTTP response, see class Response for details - */ - @Deprecated - public Response serve(String uri, Method method, Map<String, String> headers, Map<String, String> parms, Map<String, String> files) { - return new Response(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found"); - } - - /** - * Override this to customize the server. - * <p/> - * <p/> - * (By default, this returns a 404 "Not Found" plain text error response.) - * - * @param session - * The HTTP session - * @return HTTP response, see class Response for details - */ - public Response serve(IHTTPSession session) { - Map<String, String> files = new HashMap<String, String>(); - Method method = session.getMethod(); - if (Method.PUT.equals(method) || Method.POST.equals(method)) { - try { - session.parseBody(files); - } catch (IOException ioe) { - return new Response(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); - } catch (ResponseException re) { - return new Response(re.getStatus(), MIME_PLAINTEXT, re.getMessage()); - } + @Override + public Iterator<String> iterator() { + return this.cookies.keySet().iterator(); } - Map<String, String> parms = session.getParms(); - parms.put(QUERY_STRING_PARAMETER, session.getQueryParameterString()); - return serve(session.getUri(), method, session.getHeaders(), parms, files); - } - - /** - * Decode percent encoded <code>String</code> values. - * - * @param str - * the percent encoded <code>String</code> - * @return expanded form of the input, for example "foo%20bar" becomes - * "foo bar" - */ - protected String decodePercent(String str) { - String decoded = null; - try { - decoded = URLDecoder.decode(str, "UTF8"); - } catch (UnsupportedEncodingException ignored) { - LOG.log(Level.WARNING, "Encoding not supported, ignored", ignored); + /** + * Read a cookie from the HTTP Headers. + * + * @param name + * The cookie's name. + * @return The cookie's value if it exists, null otherwise. + */ + public String read(String name) { + return this.cookies.get(name); } - return decoded; - } - /** - * Decode parameters from a URL, handing the case where a single parameter - * name might have been supplied several times, by return lists of values. - * In general these lists will contain a single element. - * - * @param parms - * original <b>NanoHTTPD</b> parameters values, as passed to the - * <code>serve()</code> method. - * @return a map of <code>String</code> (parameter name) to - * <code>List<String></code> (a list of the values supplied). - */ - protected Map<String, List<String>> decodeParameters(Map<String, String> parms) { - return this.decodeParameters(parms.get(QUERY_STRING_PARAMETER)); - } - - /** - * Decode parameters from a URL, handing the case where a single parameter - * name might have been supplied several times, by return lists of values. - * In general these lists will contain a single element. - * - * @param queryString - * a query string pulled from the URL. - * @return a map of <code>String</code> (parameter name) to - * <code>List<String></code> (a list of the values supplied). - */ - protected Map<String, List<String>> decodeParameters(String queryString) { - Map<String, List<String>> parms = new HashMap<String, List<String>>(); - if (queryString != null) { - StringTokenizer st = new StringTokenizer(queryString, "&"); - while (st.hasMoreTokens()) { - String e = st.nextToken(); - int sep = e.indexOf('='); - String propertyName = (sep >= 0) ? decodePercent(e.substring(0, sep)).trim() : decodePercent(e).trim(); - if (!parms.containsKey(propertyName)) { - parms.put(propertyName, new ArrayList<String>()); - } - String propertyValue = (sep >= 0) ? decodePercent(e.substring(sep + 1)) : null; - if (propertyValue != null) { - parms.get(propertyName).add(propertyValue); - } - } + public void set(Cookie cookie) { + this.queue.add(cookie); } - return parms; - } - - // ------------------------------------------------------------------------------- - // // - // - // Threading Strategy. - // - // ------------------------------------------------------------------------------- - // // - - /** - * Pluggable strategy for asynchronously executing requests. - * - * @param asyncRunner - * new strategy for handling threads. - */ - public void setAsyncRunner(AsyncRunner asyncRunner) { - this.asyncRunner = asyncRunner; - } - - // ------------------------------------------------------------------------------- - // // - // - // Temp file handling strategy. - // - // ------------------------------------------------------------------------------- - // // - - /** - * Pluggable strategy for creating and cleaning up temporary files. - * - * @param tempFileManagerFactory - * new strategy for handling temp files. - */ - public void setTempFileManagerFactory(TempFileManagerFactory tempFileManagerFactory) { - this.tempFileManagerFactory = tempFileManagerFactory; - } - /** - * HTTP Request methods, with the ability to decode a <code>String</code> - * back to its enum value. - */ - public enum Method { - GET, - PUT, - POST, - DELETE, - HEAD, - OPTIONS; + /** + * Sets a cookie. + * + * @param name + * The cookie's name. + * @param value + * The cookie's value. + * @param expires + * How many days until the cookie expires. + */ + public void set(String name, String value, int expires) { + this.queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires))); + } - static Method lookup(String method) { - for (Method m : Method.values()) { - if (m.toString().equalsIgnoreCase(method)) { - return m; - } + /** + * Internally used by the webserver to add all queued cookies into the + * Response's HTTP Headers. + * + * @param response + * The Response object to which headers the queued cookies + * will be added. + */ + public void unloadQueue(Response response) { + for (Cookie cookie : this.queue) { + response.addHeader("Set-Cookie", cookie.getHTTPHeader()); } - return null; } } /** - * Pluggable strategy for asynchronously executing requests. - */ - public interface AsyncRunner { - - void exec(Runnable code); - } - - /** - * Factory to create temp file managers. - */ - public interface TempFileManagerFactory { - - TempFileManager create(); - } - - // ------------------------------------------------------------------------------- - // // - - /** - * Temp file manager. - * <p/> - * <p> - * Temp file managers are created 1-to-1 with incoming requests, to create - * and cleanup temporary files created as a result of handling the request. - * </p> - */ - public interface TempFileManager { - - TempFile createTempFile() throws Exception; - - void clear(); - } - - /** - * A temp file. - * <p/> - * <p> - * Temp files are responsible for managing the actual temporary storage and - * cleaning themselves up when no longer needed. - * </p> - */ - public interface TempFile { - - OutputStream open() throws Exception; - - void delete() throws Exception; - - String getName(); - } - - /** * Default threading strategy for NanoHTTPD. * <p/> * <p> @@ -636,10 +276,10 @@ public abstract class NanoHTTPD { @Override public void exec(Runnable code) { - ++requestCount; + ++this.requestCount; Thread t = new Thread(code); t.setDaemon(true); - t.setName("NanoHttpd Request Processor (#" + requestCount + ")"); + t.setName("NanoHttpd Request Processor (#" + this.requestCount + ")"); t.start(); } } @@ -648,365 +288,76 @@ public abstract class NanoHTTPD { * Default strategy for creating and cleaning up temporary files. * <p/> * <p> - * This class stores its files in the standard location (that is, wherever - * <code>java.io.tmpdir</code> points to). Files are added to an internal - * list, and deleted when no longer needed (that is, when - * <code>clear()</code> is invoked at the end of processing a request). - * </p> - */ - public static class DefaultTempFileManager implements TempFileManager { - - private final String tmpdir; - - private final List<TempFile> tempFiles; - - public DefaultTempFileManager() { - tmpdir = System.getProperty("java.io.tmpdir"); - tempFiles = new ArrayList<TempFile>(); - } - - @Override - public TempFile createTempFile() throws Exception { - DefaultTempFile tempFile = new DefaultTempFile(tmpdir); - tempFiles.add(tempFile); - return tempFile; - } - - @Override - public void clear() { - for (TempFile file : tempFiles) { - try { - file.delete(); - } catch (Exception ignored) { - LOG.log(Level.WARNING, "could not delete file ", ignored); - } - } - tempFiles.clear(); - } - } - - /** - * Default strategy for creating and cleaning up temporary files. - * <p/> - * <p> * By default, files are created by <code>File.createTempFile()</code> in * the directory specified. * </p> */ public static class DefaultTempFile implements TempFile { - private File file; + private final File file; - private OutputStream fstream; + private final OutputStream fstream; public DefaultTempFile(String tempdir) throws IOException { - file = File.createTempFile("NanoHTTPD-", "", new File(tempdir)); - fstream = new FileOutputStream(file); + this.file = File.createTempFile("NanoHTTPD-", "", new File(tempdir)); + this.fstream = new FileOutputStream(this.file); } @Override - public OutputStream open() throws Exception { - return fstream; + public void delete() throws Exception { + safeClose(this.fstream); + this.file.delete(); } @Override - public void delete() throws Exception { - safeClose(fstream); - file.delete(); + public String getName() { + return this.file.getAbsolutePath(); } @Override - public String getName() { - return file.getAbsolutePath(); + public OutputStream open() throws Exception { + return this.fstream; } } /** - * HTTP response. Return one of these from serve(). + * Default strategy for creating and cleaning up temporary files. + * <p/> + * <p> + * This class stores its files in the standard location (that is, wherever + * <code>java.io.tmpdir</code> points to). Files are added to an internal + * list, and deleted when no longer needed (that is, when + * <code>clear()</code> is invoked at the end of processing a request). + * </p> */ - public static class Response { - - /** - * HTTP status code after processing, e.g. "200 OK", Status.OK - */ - private IStatus status; - - /** - * MIME type of content, e.g. "text/html" - */ - private String mimeType; - - /** - * Data of the response, may be null. - */ - private InputStream data; - - /** - * Headers for the HTTP response. Use addHeader() to add lines. - */ - private Map<String, String> header = new HashMap<String, String>(); - - /** - * The request method that spawned this response. - */ - private Method requestMethod; - - /** - * Use chunkedTransfer - */ - private boolean chunkedTransfer; - - /** - * Default constructor: response = Status.OK, mime = MIME_HTML and your - * supplied message - */ - public Response(String msg) { - this(Status.OK, MIME_HTML, msg); - } - - /** - * Basic constructor. - */ - public Response(IStatus status, String mimeType, InputStream data) { - this.status = status; - this.mimeType = mimeType; - this.data = data; - } - - /** - * Convenience method that makes an InputStream out of given text. - */ - public Response(IStatus status, String mimeType, String txt) { - this.status = status; - this.mimeType = mimeType; - try { - this.data = txt != null ? new ByteArrayInputStream(txt.getBytes("UTF-8")) : null; - } catch (java.io.UnsupportedEncodingException uee) { - LOG.log(Level.SEVERE, "encoding problem", uee); - } - } - - /** - * Adds given line to the header. - */ - public void addHeader(String name, String value) { - header.put(name, value); - } - - public String getHeader(String name) { - return header.get(name); - } - - /** - * Sends given response to the socket. - */ - protected void send(OutputStream outputStream) { - String mime = mimeType; - SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US); - gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT")); - - try { - if (status == null) { - throw new Error("sendResponse(): Status can't be null."); - } - PrintWriter pw = new PrintWriter(outputStream); - pw.print("HTTP/1.1 " + status.getDescription() + " \r\n"); - - if (mime != null) { - pw.print("Content-Type: " + mime + "\r\n"); - } - - if (header == null || header.get("Date") == null) { - pw.print("Date: " + gmtFrmt.format(new Date()) + "\r\n"); - } - - if (header != null) { - for (String key : header.keySet()) { - String value = header.get(key); - pw.print(key + ": " + value + "\r\n"); - } - } - - sendConnectionHeaderIfNotAlreadyPresent(pw, header); - - if (requestMethod != Method.HEAD && chunkedTransfer) { - sendAsChunked(outputStream, pw); - } else { - int pending = data != null ? data.available() : 0; - pending = sendContentLengthHeaderIfNotAlreadyPresent(pw, header, pending); - pw.print("\r\n"); - pw.flush(); - sendAsFixedLength(outputStream, pending); - } - outputStream.flush(); - safeClose(data); - } catch (IOException ioe) { - LOG.log(Level.SEVERE, "Could not send response to the client", ioe); - } - } - - protected int sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, Map<String, String> header, int size) { - for (String headerName : header.keySet()) { - if (headerName.equalsIgnoreCase("content-length")) { - try { - return Integer.parseInt(header.get(headerName)); - } catch (NumberFormatException ex) { - return size; - } - } - } - - pw.print("Content-Length: " + size + "\r\n"); - return size; - } + public static class DefaultTempFileManager implements TempFileManager { - protected void sendConnectionHeaderIfNotAlreadyPresent(PrintWriter pw, Map<String, String> header) { - if (!headerAlreadySent(header, "connection")) { - pw.print("Connection: keep-alive\r\n"); - } - } + private final String tmpdir; - private boolean headerAlreadySent(Map<String, String> header, String name) { - boolean alreadySent = false; - for (String headerName : header.keySet()) { - alreadySent |= headerName.equalsIgnoreCase(name); - } - return alreadySent; - } + private final List<TempFile> tempFiles; - private void sendAsChunked(OutputStream outputStream, PrintWriter pw) throws IOException { - pw.print("Transfer-Encoding: chunked\r\n"); - pw.print("\r\n"); - pw.flush(); - int BUFFER_SIZE = 16 * 1024; - byte[] CRLF = "\r\n".getBytes(); - byte[] buff = new byte[BUFFER_SIZE]; - int read; - while ((read = data.read(buff)) > 0) { - outputStream.write(String.format("%x\r\n", read).getBytes()); - outputStream.write(buff, 0, read); - outputStream.write(CRLF); - } - outputStream.write(String.format("0\r\n\r\n").getBytes()); + public DefaultTempFileManager() { + this.tmpdir = System.getProperty("java.io.tmpdir"); + this.tempFiles = new ArrayList<TempFile>(); } - private void sendAsFixedLength(OutputStream outputStream, int pending) throws IOException { - if (requestMethod != Method.HEAD && data != null) { - int BUFFER_SIZE = 16 * 1024; - byte[] buff = new byte[BUFFER_SIZE]; - while (pending > 0) { - int read = data.read(buff, 0, ((pending > BUFFER_SIZE) ? BUFFER_SIZE : pending)); - if (read <= 0) { - break; - } - outputStream.write(buff, 0, read); - pending -= read; + @Override + public void clear() { + for (TempFile file : this.tempFiles) { + try { + file.delete(); + } catch (Exception ignored) { + NanoHTTPD.LOG.log(Level.WARNING, "could not delete file ", ignored); } } + this.tempFiles.clear(); } - public IStatus getStatus() { - return status; - } - - public void setStatus(IStatus status) { - this.status = status; - } - - public String getMimeType() { - return mimeType; - } - - public void setMimeType(String mimeType) { - this.mimeType = mimeType; - } - - public InputStream getData() { - return data; - } - - public void setData(InputStream data) { - this.data = data; - } - - public Method getRequestMethod() { - return requestMethod; - } - - public void setRequestMethod(Method requestMethod) { - this.requestMethod = requestMethod; - } - - public void setChunkedTransfer(boolean chunkedTransfer) { - this.chunkedTransfer = chunkedTransfer; - } - - public interface IStatus { - - int getRequestStatus(); - - String getDescription(); - } - - /** - * Some HTTP response status codes - */ - public enum Status implements IStatus { - SWITCH_PROTOCOL(101, "Switching Protocols"), - OK(200, "OK"), - CREATED(201, "Created"), - ACCEPTED(202, "Accepted"), - NO_CONTENT(204, "No Content"), - PARTIAL_CONTENT(206, "Partial Content"), - REDIRECT(301, "Moved Permanently"), - NOT_MODIFIED(304, "Not Modified"), - BAD_REQUEST(400, "Bad Request"), - UNAUTHORIZED(401, "Unauthorized"), - FORBIDDEN(403, "Forbidden"), - NOT_FOUND(404, "Not Found"), - METHOD_NOT_ALLOWED(405, "Method Not Allowed"), - RANGE_NOT_SATISFIABLE(416, "Requested Range Not Satisfiable"), - INTERNAL_ERROR(500, "Internal Server Error"); - - private final int requestStatus; - - private final String description; - - Status(int requestStatus, String description) { - this.requestStatus = requestStatus; - this.description = description; - } - - @Override - public int getRequestStatus() { - return this.requestStatus; - } - - @Override - public String getDescription() { - return "" + this.requestStatus + " " + description; - } - } - } - - public static final class ResponseException extends Exception { - - private static final long serialVersionUID = 6569838532917408380L; - - private final Response.Status status; - - public ResponseException(Response.Status status, String message) { - super(message); - this.status = status; - } - - public ResponseException(Response.Status status, String message, Exception e) { - super(message, e); - this.status = status; - } - - public Response.Status getStatus() { - return status; + @Override + public TempFile createTempFile() throws Exception { + DefaultTempFile tempFile = new DefaultTempFile(this.tmpdir); + this.tempFiles.add(tempFile); + return tempFile; } } @@ -1021,40 +372,6 @@ public abstract class NanoHTTPD { } } - /** - * Handles one session, i.e. parses the HTTP request and returns the - * response. - */ - public interface IHTTPSession { - - void execute() throws IOException; - - Map<String, String> getParms(); - - Map<String, String> getHeaders(); - - /** - * @return the path part of the URL. - */ - String getUri(); - - String getQueryParameterString(); - - Method getMethod(); - - InputStream getInputStream(); - - CookieHandler getCookies(); - - /** - * Adds the files in the request body to the files map. - * - * @param files - * map to modify - */ - void parseBody(Map<String, String> files) throws IOException, ResponseException; - } - protected class HTTPSession implements IHTTPSession { public static final int BUFSIZE = 8192; @@ -1063,7 +380,7 @@ public abstract class NanoHTTPD { private final OutputStream outputStream; - private PushbackInputStream inputStream; + private final PushbackInputStream inputStream; private int splitbyte; @@ -1085,207 +402,16 @@ public abstract class NanoHTTPD { public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) { this.tempFileManager = tempFileManager; - this.inputStream = new PushbackInputStream(inputStream, BUFSIZE); + this.inputStream = new PushbackInputStream(inputStream, HTTPSession.BUFSIZE); this.outputStream = outputStream; } public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) { this.tempFileManager = tempFileManager; - this.inputStream = new PushbackInputStream(inputStream, BUFSIZE); + this.inputStream = new PushbackInputStream(inputStream, HTTPSession.BUFSIZE); this.outputStream = outputStream; - remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString(); - headers = new HashMap<String, String>(); - } - - @Override - public void execute() throws IOException { - try { - // Read the first 8192 bytes. - // The full header should fit in here. - // Apache's default header limit is 8KB. - // Do NOT assume that a single read will get the entire header - // at once! - byte[] buf = new byte[BUFSIZE]; - splitbyte = 0; - rlen = 0; - { - int read = -1; - try { - read = inputStream.read(buf, 0, BUFSIZE); - } catch (Exception e) { - safeClose(inputStream); - safeClose(outputStream); - throw new SocketException("NanoHttpd Shutdown"); - } - if (read == -1) { - // socket was been closed - safeClose(inputStream); - safeClose(outputStream); - throw new SocketException("NanoHttpd Shutdown"); - } - while (read > 0) { - rlen += read; - splitbyte = findHeaderEnd(buf, rlen); - if (splitbyte > 0) - break; - read = inputStream.read(buf, rlen, BUFSIZE - rlen); - } - } - - if (splitbyte < rlen) { - inputStream.unread(buf, splitbyte, rlen - splitbyte); - } - - parms = new HashMap<String, String>(); - if (null == headers) { - headers = new HashMap<String, String>(); - } else { - headers.clear(); - } - - if (null != remoteIp) { - headers.put("remote-addr", remoteIp); - headers.put("http-client-ip", remoteIp); - } - - // Create a BufferedReader for parsing the header. - BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, rlen))); - - // Decode the header into parms and header java properties - Map<String, String> pre = new HashMap<String, String>(); - decodeHeader(hin, pre, parms, headers); - - method = Method.lookup(pre.get("method")); - if (method == null) { - throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error."); - } - - uri = pre.get("uri"); - - cookies = new CookieHandler(headers); - - // Ok, now do the serve() - Response r = serve(this); - if (r == null) { - throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response."); - } else { - cookies.unloadQueue(r); - r.setRequestMethod(method); - r.send(outputStream); - } - } catch (SocketException e) { - // throw it out to close socket object (finalAccept) - throw e; - } catch (SocketTimeoutException ste) { - // treat socket timeouts the same way we treat socket exceptions - // i.e. close the stream & finalAccept object by throwing the - // exception up the call stack. - throw ste; - } catch (IOException ioe) { - Response r = new Response(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); - r.send(outputStream); - safeClose(outputStream); - } catch (ResponseException re) { - Response r = new Response(re.getStatus(), MIME_PLAINTEXT, re.getMessage()); - r.send(outputStream); - safeClose(outputStream); - } finally { - tempFileManager.clear(); - } - } - - @Override - public void parseBody(Map<String, String> files) throws IOException, ResponseException { - RandomAccessFile randomAccessFile = null; - BufferedReader in = null; - try { - - randomAccessFile = getTmpBucket(); - - long size; - if (headers.containsKey("content-length")) { - size = Integer.parseInt(headers.get("content-length")); - } else if (splitbyte < rlen) { - size = rlen - splitbyte; - } else { - size = 0; - } - - // Now read all the body and write it to f - byte[] buf = new byte[512]; - while (rlen >= 0 && size > 0) { - rlen = inputStream.read(buf, 0, (int) Math.min(size, 512)); - size -= rlen; - if (rlen > 0) { - randomAccessFile.write(buf, 0, rlen); - } - } - - // Get the raw body as a byte [] - ByteBuffer fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length()); - randomAccessFile.seek(0); - - // Create a BufferedReader for easily reading it as string. - InputStream bin = new FileInputStream(randomAccessFile.getFD()); - in = new BufferedReader(new InputStreamReader(bin)); - - // If the method is POST, there may be parameters - // in data section, too, read it: - if (Method.POST.equals(method)) { - String contentType = ""; - String contentTypeHeader = headers.get("content-type"); - - StringTokenizer st = null; - if (contentTypeHeader != null) { - st = new StringTokenizer(contentTypeHeader, ",; "); - if (st.hasMoreTokens()) { - contentType = st.nextToken(); - } - } - - if ("multipart/form-data".equalsIgnoreCase(contentType)) { - // Handle multipart/form-data - if (!st.hasMoreTokens()) { - throw new ResponseException(Response.Status.BAD_REQUEST, - "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html"); - } - - String boundaryStartString = "boundary="; - int boundaryContentStart = contentTypeHeader.indexOf(boundaryStartString) + boundaryStartString.length(); - String boundary = contentTypeHeader.substring(boundaryContentStart, contentTypeHeader.length()); - if (boundary.startsWith("\"") && boundary.endsWith("\"")) { - boundary = boundary.substring(1, boundary.length() - 1); - } - - decodeMultipartData(boundary, fbuf, in, parms, files); - } else { - String postLine = ""; - StringBuilder postLineBuffer = new StringBuilder(); - char pbuf[] = new char[512]; - int read = in.read(pbuf); - while (read >= 0 && !postLine.endsWith("\r\n")) { - postLine = String.valueOf(pbuf, 0, read); - postLineBuffer.append(postLine); - read = in.read(pbuf); - } - postLine = postLineBuffer.toString().trim(); - // Handle application/x-www-form-urlencoded - if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType)) { - decodeParms(postLine, parms); - } else if (postLine.length() != 0) { - // Special case for raw POST data => create a - // special files entry "postData" with raw content - // data - files.put("postData", postLine); - } - } - } else if (Method.PUT.equals(method)) { - files.put("content", saveTmpFile(fbuf, 0, fbuf.limit())); - } - } finally { - safeClose(randomAccessFile); - safeClose(in); - } + this.remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString(); + this.headers = new HashMap<String, String>(); } /** @@ -1326,13 +452,14 @@ public abstract class NanoHTTPD { // NOTE: this now forces header names lower case since they are // case insensitive and vary by client. if (!st.hasMoreTokens()) { - LOG.log(Level.FINE, "no protocol version specified, strange.."); + NanoHTTPD.LOG.log(Level.FINE, "no protocol version specified, strange.."); } String line = in.readLine(); while (line != null && line.trim().length() > 0) { int p = line.indexOf(':'); - if (p >= 0) + if (p >= 0) { headers.put(line.substring(0, p).trim().toLowerCase(Locale.US), line.substring(p + 1).trim()); + } line = in.readLine(); } @@ -1426,6 +553,129 @@ public abstract class NanoHTTPD { } /** + * Decodes parameters in percent-encoded URI-format ( e.g. + * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given + * Map. NOTE: this doesn't support multiple identical keys due to the + * simplicity of Map. + */ + private void decodeParms(String parms, Map<String, String> p) { + if (parms == null) { + this.queryParameterString = ""; + return; + } + + this.queryParameterString = parms; + StringTokenizer st = new StringTokenizer(parms, "&"); + while (st.hasMoreTokens()) { + String e = st.nextToken(); + int sep = e.indexOf('='); + if (sep >= 0) { + p.put(decodePercent(e.substring(0, sep)).trim(), decodePercent(e.substring(sep + 1))); + } else { + p.put(decodePercent(e).trim(), ""); + } + } + } + + @Override + public void execute() throws IOException { + try { + // Read the first 8192 bytes. + // The full header should fit in here. + // Apache's default header limit is 8KB. + // Do NOT assume that a single read will get the entire header + // at once! + byte[] buf = new byte[HTTPSession.BUFSIZE]; + this.splitbyte = 0; + this.rlen = 0; + { + int read = -1; + try { + read = this.inputStream.read(buf, 0, HTTPSession.BUFSIZE); + } catch (Exception e) { + safeClose(this.inputStream); + safeClose(this.outputStream); + throw new SocketException("NanoHttpd Shutdown"); + } + if (read == -1) { + // socket was been closed + safeClose(this.inputStream); + safeClose(this.outputStream); + throw new SocketException("NanoHttpd Shutdown"); + } + while (read > 0) { + this.rlen += read; + this.splitbyte = findHeaderEnd(buf, this.rlen); + if (this.splitbyte > 0) { + break; + } + read = this.inputStream.read(buf, this.rlen, HTTPSession.BUFSIZE - this.rlen); + } + } + + if (this.splitbyte < this.rlen) { + this.inputStream.unread(buf, this.splitbyte, this.rlen - this.splitbyte); + } + + this.parms = new HashMap<String, String>(); + if (null == this.headers) { + this.headers = new HashMap<String, String>(); + } else { + this.headers.clear(); + } + + if (null != this.remoteIp) { + this.headers.put("remote-addr", this.remoteIp); + this.headers.put("http-client-ip", this.remoteIp); + } + + // Create a BufferedReader for parsing the header. + BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, this.rlen))); + + // Decode the header into parms and header java properties + Map<String, String> pre = new HashMap<String, String>(); + decodeHeader(hin, pre, this.parms, this.headers); + + this.method = Method.lookup(pre.get("method")); + if (this.method == null) { + throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error."); + } + + this.uri = pre.get("uri"); + + this.cookies = new CookieHandler(this.headers); + + // Ok, now do the serve() + Response r = serve(this); + if (r == null) { + throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response."); + } else { + this.cookies.unloadQueue(r); + r.setRequestMethod(this.method); + r.send(this.outputStream); + } + } catch (SocketException e) { + // throw it out to close socket object (finalAccept) + throw e; + } catch (SocketTimeoutException ste) { + // treat socket timeouts the same way we treat socket exceptions + // i.e. close the stream & finalAccept object by throwing the + // exception up the call stack. + throw ste; + } catch (IOException ioe) { + Response r = new Response(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); + r.send(this.outputStream); + safeClose(this.outputStream); + } catch (ResponseException re) { + Response r = new Response(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage()); + r.send(this.outputStream); + safeClose(this.outputStream); + } finally { + this.tempFileManager.clear(); + } + } + + /** * Find byte index separating header from body. It must be the last byte * of the first two sequential new lines. */ @@ -1449,8 +699,9 @@ public abstract class NanoHTTPD { List<Integer> matchbytes = new ArrayList<Integer>(); for (int i = 0; i < b.limit(); i++) { if (b.get(i) == boundary[matchcount]) { - if (matchcount == 0) + if (matchcount == 0) { matchbyte = i; + } matchcount++; if (matchcount == boundary.length) { matchbytes.add(matchbyte); @@ -1470,6 +721,144 @@ public abstract class NanoHTTPD { return ret; } + @Override + public CookieHandler getCookies() { + return this.cookies; + } + + @Override + public final Map<String, String> getHeaders() { + return this.headers; + } + + @Override + public final InputStream getInputStream() { + return this.inputStream; + } + + @Override + public final Method getMethod() { + return this.method; + } + + @Override + public final Map<String, String> getParms() { + return this.parms; + } + + @Override + public String getQueryParameterString() { + return this.queryParameterString; + } + + private RandomAccessFile getTmpBucket() { + try { + TempFile tempFile = this.tempFileManager.createTempFile(); + return new RandomAccessFile(tempFile.getName(), "rw"); + } catch (Exception e) { + throw new Error(e); // we won't recover, so throw an error + } + } + + @Override + public final String getUri() { + return this.uri; + } + + @Override + public void parseBody(Map<String, String> files) throws IOException, ResponseException { + RandomAccessFile randomAccessFile = null; + BufferedReader in = null; + try { + + randomAccessFile = getTmpBucket(); + + long size; + if (this.headers.containsKey("content-length")) { + size = Integer.parseInt(this.headers.get("content-length")); + } else if (this.splitbyte < this.rlen) { + size = this.rlen - this.splitbyte; + } else { + size = 0; + } + + // Now read all the body and write it to f + byte[] buf = new byte[512]; + while (this.rlen >= 0 && size > 0) { + this.rlen = this.inputStream.read(buf, 0, (int) Math.min(size, 512)); + size -= this.rlen; + if (this.rlen > 0) { + randomAccessFile.write(buf, 0, this.rlen); + } + } + + // Get the raw body as a byte [] + ByteBuffer fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length()); + randomAccessFile.seek(0); + + // Create a BufferedReader for easily reading it as string. + InputStream bin = new FileInputStream(randomAccessFile.getFD()); + in = new BufferedReader(new InputStreamReader(bin)); + + // If the method is POST, there may be parameters + // in data section, too, read it: + if (Method.POST.equals(this.method)) { + String contentType = ""; + String contentTypeHeader = this.headers.get("content-type"); + + StringTokenizer st = null; + if (contentTypeHeader != null) { + st = new StringTokenizer(contentTypeHeader, ",; "); + if (st.hasMoreTokens()) { + contentType = st.nextToken(); + } + } + + if ("multipart/form-data".equalsIgnoreCase(contentType)) { + // Handle multipart/form-data + if (!st.hasMoreTokens()) { + throw new ResponseException(Response.Status.BAD_REQUEST, + "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html"); + } + + String boundaryStartString = "boundary="; + int boundaryContentStart = contentTypeHeader.indexOf(boundaryStartString) + boundaryStartString.length(); + String boundary = contentTypeHeader.substring(boundaryContentStart, contentTypeHeader.length()); + if (boundary.startsWith("\"") && boundary.endsWith("\"")) { + boundary = boundary.substring(1, boundary.length() - 1); + } + + decodeMultipartData(boundary, fbuf, in, this.parms, files); + } else { + String postLine = ""; + StringBuilder postLineBuffer = new StringBuilder(); + char pbuf[] = new char[512]; + int read = in.read(pbuf); + while (read >= 0 && !postLine.endsWith("\r\n")) { + postLine = String.valueOf(pbuf, 0, read); + postLineBuffer.append(postLine); + read = in.read(pbuf); + } + postLine = postLineBuffer.toString().trim(); + // Handle application/x-www-form-urlencoded + if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType)) { + decodeParms(postLine, this.parms); + } else if (postLine.length() != 0) { + // Special case for raw POST data => create a + // special files entry "postData" with raw content + // data + files.put("postData", postLine); + } + } + } else if (Method.PUT.equals(this.method)) { + files.put("content", saveTmpFile(fbuf, 0, fbuf.limit())); + } + } finally { + safeClose(randomAccessFile); + safeClose(in); + } + } + /** * Retrieves the content of a sent file and saves it to a temporary * file. The full path to the saved file is returned. @@ -1479,7 +868,7 @@ public abstract class NanoHTTPD { if (len > 0) { FileOutputStream fileOutputStream = null; try { - TempFile tempFile = tempFileManager.createTempFile(); + TempFile tempFile = this.tempFileManager.createTempFile(); ByteBuffer src = b.duplicate(); fileOutputStream = new FileOutputStream(tempFile.getName()); FileChannel dest = fileOutputStream.getChannel(); @@ -1495,15 +884,6 @@ public abstract class NanoHTTPD { return path; } - private RandomAccessFile getTmpBucket() { - try { - TempFile tempFile = tempFileManager.createTempFile(); - return new RandomAccessFile(tempFile.getName(), "rw"); - } catch (Exception e) { - throw new Error(e); // we won't recover, so throw an error - } - } - /** * It returns the offset separating multipart file headers from the * file's data. @@ -1517,184 +897,813 @@ public abstract class NanoHTTPD { } return i + 1; } + } + + /** + * Handles one session, i.e. parses the HTTP request and returns the + * response. + */ + public interface IHTTPSession { + + void execute() throws IOException; + + CookieHandler getCookies(); + + Map<String, String> getHeaders(); + + InputStream getInputStream(); + + Method getMethod(); + + Map<String, String> getParms(); + + String getQueryParameterString(); /** - * Decodes parameters in percent-encoded URI-format ( e.g. - * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given - * Map. NOTE: this doesn't support multiple identical keys due to the - * simplicity of Map. + * @return the path part of the URL. */ - private void decodeParms(String parms, Map<String, String> p) { - if (parms == null) { - queryParameterString = ""; - return; + String getUri(); + + /** + * Adds the files in the request body to the files map. + * + * @param files + * map to modify + */ + void parseBody(Map<String, String> files) throws IOException, ResponseException; + } + + /** + * HTTP Request methods, with the ability to decode a <code>String</code> + * back to its enum value. + */ + public enum Method { + GET, + PUT, + POST, + DELETE, + HEAD, + OPTIONS; + + static Method lookup(String method) { + for (Method m : Method.values()) { + if (m.toString().equalsIgnoreCase(method)) { + return m; + } } + return null; + } + } - queryParameterString = parms; - StringTokenizer st = new StringTokenizer(parms, "&"); - while (st.hasMoreTokens()) { - String e = st.nextToken(); - int sep = e.indexOf('='); - if (sep >= 0) { - p.put(decodePercent(e.substring(0, sep)).trim(), decodePercent(e.substring(sep + 1))); + /** + * HTTP response. Return one of these from serve(). + */ + public static class Response { + + public interface IStatus { + + String getDescription(); + + int getRequestStatus(); + } + + /** + * Some HTTP response status codes + */ + public enum Status implements IStatus { + SWITCH_PROTOCOL(101, "Switching Protocols"), + OK(200, "OK"), + CREATED(201, "Created"), + ACCEPTED(202, "Accepted"), + NO_CONTENT(204, "No Content"), + PARTIAL_CONTENT(206, "Partial Content"), + REDIRECT(301, "Moved Permanently"), + NOT_MODIFIED(304, "Not Modified"), + BAD_REQUEST(400, "Bad Request"), + UNAUTHORIZED(401, "Unauthorized"), + FORBIDDEN(403, "Forbidden"), + NOT_FOUND(404, "Not Found"), + METHOD_NOT_ALLOWED(405, "Method Not Allowed"), + RANGE_NOT_SATISFIABLE(416, "Requested Range Not Satisfiable"), + INTERNAL_ERROR(500, "Internal Server Error"); + + private final int requestStatus; + + private final String description; + + Status(int requestStatus, String description) { + this.requestStatus = requestStatus; + this.description = description; + } + + @Override + public String getDescription() { + return "" + this.requestStatus + " " + this.description; + } + + @Override + public int getRequestStatus() { + return this.requestStatus; + } + } + + /** + * HTTP status code after processing, e.g. "200 OK", Status.OK + */ + private IStatus status; + + /** + * MIME type of content, e.g. "text/html" + */ + private String mimeType; + + /** + * Data of the response, may be null. + */ + private InputStream data; + + /** + * Headers for the HTTP response. Use addHeader() to add lines. + */ + private final Map<String, String> header = new HashMap<String, String>(); + + /** + * The request method that spawned this response. + */ + private Method requestMethod; + + /** + * Use chunkedTransfer + */ + private boolean chunkedTransfer; + + /** + * Basic constructor. + */ + public Response(IStatus status, String mimeType, InputStream data) { + this.status = status; + this.mimeType = mimeType; + this.data = data; + } + + /** + * Convenience method that makes an InputStream out of given text. + */ + public Response(IStatus status, String mimeType, String txt) { + this.status = status; + this.mimeType = mimeType; + try { + this.data = txt != null ? new ByteArrayInputStream(txt.getBytes("UTF-8")) : null; + } catch (java.io.UnsupportedEncodingException uee) { + NanoHTTPD.LOG.log(Level.SEVERE, "encoding problem", uee); + } + } + + /** + * Default constructor: response = Status.OK, mime = MIME_HTML and your + * supplied message + */ + public Response(String msg) { + this(Status.OK, NanoHTTPD.MIME_HTML, msg); + } + + /** + * Adds given line to the header. + */ + public void addHeader(String name, String value) { + this.header.put(name, value); + } + + public InputStream getData() { + return this.data; + } + + public String getHeader(String name) { + return this.header.get(name); + } + + public String getMimeType() { + return this.mimeType; + } + + public Method getRequestMethod() { + return this.requestMethod; + } + + public IStatus getStatus() { + return this.status; + } + + private boolean headerAlreadySent(Map<String, String> header, String name) { + boolean alreadySent = false; + for (String headerName : header.keySet()) { + alreadySent |= headerName.equalsIgnoreCase(name); + } + return alreadySent; + } + + /** + * Sends given response to the socket. + */ + protected void send(OutputStream outputStream) { + String mime = this.mimeType; + SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US); + gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT")); + + try { + if (this.status == null) { + throw new Error("sendResponse(): Status can't be null."); + } + PrintWriter pw = new PrintWriter(outputStream); + pw.print("HTTP/1.1 " + this.status.getDescription() + " \r\n"); + + if (mime != null) { + pw.print("Content-Type: " + mime + "\r\n"); + } + + if (this.header == null || this.header.get("Date") == null) { + pw.print("Date: " + gmtFrmt.format(new Date()) + "\r\n"); + } + + if (this.header != null) { + for (String key : this.header.keySet()) { + String value = this.header.get(key); + pw.print(key + ": " + value + "\r\n"); + } + } + + sendConnectionHeaderIfNotAlreadyPresent(pw, this.header); + + if (this.requestMethod != Method.HEAD && this.chunkedTransfer) { + sendAsChunked(outputStream, pw); } else { - p.put(decodePercent(e).trim(), ""); + int pending = this.data != null ? this.data.available() : 0; + pending = sendContentLengthHeaderIfNotAlreadyPresent(pw, this.header, pending); + pw.print("\r\n"); + pw.flush(); + sendAsFixedLength(outputStream, pending); } + outputStream.flush(); + safeClose(this.data); + } catch (IOException ioe) { + NanoHTTPD.LOG.log(Level.SEVERE, "Could not send response to the client", ioe); } } - @Override - public final Map<String, String> getParms() { - return parms; + private void sendAsChunked(OutputStream outputStream, PrintWriter pw) throws IOException { + pw.print("Transfer-Encoding: chunked\r\n"); + pw.print("\r\n"); + pw.flush(); + int BUFFER_SIZE = 16 * 1024; + byte[] CRLF = "\r\n".getBytes(); + byte[] buff = new byte[BUFFER_SIZE]; + int read; + while ((read = this.data.read(buff)) > 0) { + outputStream.write(String.format("%x\r\n", read).getBytes()); + outputStream.write(buff, 0, read); + outputStream.write(CRLF); + } + outputStream.write(String.format("0\r\n\r\n").getBytes()); } - public String getQueryParameterString() { - return queryParameterString; + private void sendAsFixedLength(OutputStream outputStream, int pending) throws IOException { + if (this.requestMethod != Method.HEAD && this.data != null) { + int BUFFER_SIZE = 16 * 1024; + byte[] buff = new byte[BUFFER_SIZE]; + while (pending > 0) { + int read = this.data.read(buff, 0, pending > BUFFER_SIZE ? BUFFER_SIZE : pending); + if (read <= 0) { + break; + } + outputStream.write(buff, 0, read); + pending -= read; + } + } } - @Override - public final Map<String, String> getHeaders() { - return headers; + protected void sendConnectionHeaderIfNotAlreadyPresent(PrintWriter pw, Map<String, String> header) { + if (!headerAlreadySent(header, "connection")) { + pw.print("Connection: keep-alive\r\n"); + } } - @Override - public final String getUri() { - return uri; + protected int sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, Map<String, String> header, int size) { + for (String headerName : header.keySet()) { + if (headerName.equalsIgnoreCase("content-length")) { + try { + return Integer.parseInt(header.get(headerName)); + } catch (NumberFormatException ex) { + return size; + } + } + } + + pw.print("Content-Length: " + size + "\r\n"); + return size; } - @Override - public final Method getMethod() { - return method; + public void setChunkedTransfer(boolean chunkedTransfer) { + this.chunkedTransfer = chunkedTransfer; } - @Override - public final InputStream getInputStream() { - return inputStream; + public void setData(InputStream data) { + this.data = data; } - @Override - public CookieHandler getCookies() { - return cookies; + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public void setRequestMethod(Method requestMethod) { + this.requestMethod = requestMethod; + } + + public void setStatus(IStatus status) { + this.status = status; } } - public static class Cookie { + public static final class ResponseException extends Exception { + + private static final long serialVersionUID = 6569838532917408380L; - private String n, v, e; + private final Response.Status status; - public Cookie(String name, String value, String expires) { - n = name; - v = value; - e = expires; + public ResponseException(Response.Status status, String message) { + super(message); + this.status = status; } - public Cookie(String name, String value) { - this(name, value, 30); + public ResponseException(Response.Status status, String message, Exception e) { + super(message, e); + this.status = status; } - public Cookie(String name, String value, int numDays) { - n = name; - v = value; - e = getHTTPTime(numDays); + public Response.Status getStatus() { + return this.status; } + } - public String getHTTPHeader() { - String fmt = "%s=%s; expires=%s"; - return String.format(fmt, n, v, e); + /** + * A temp file. + * <p/> + * <p> + * Temp files are responsible for managing the actual temporary storage and + * cleaning themselves up when no longer needed. + * </p> + */ + public interface TempFile { + + void delete() throws Exception; + + String getName(); + + OutputStream open() throws Exception; + } + + /** + * Temp file manager. + * <p/> + * <p> + * Temp file managers are created 1-to-1 with incoming requests, to create + * and cleanup temporary files created as a result of handling the request. + * </p> + */ + public interface TempFileManager { + + void clear(); + + TempFile createTempFile() throws Exception; + } + + /** + * Factory to create temp file managers. + */ + public interface TempFileManagerFactory { + + TempFileManager create(); + } + + /** + * Maximum time to wait on Socket.getInputStream().read() (in milliseconds) + * This is required as the Keep-Alive HTTP connections would otherwise block + * the socket reading thread forever (or as long the browser is open). + */ + public static final int SOCKET_READ_TIMEOUT = 5000; + + /** + * Common MIME type for dynamic content: plain text + */ + public static final String MIME_PLAINTEXT = "text/plain"; + + /** + * Common MIME type for dynamic content: html + */ + public static final String MIME_HTML = "text/html"; + + /** + * Pseudo-Parameter to use to store the actual query string in the + * parameters map for later re-processing. + */ + private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING"; + + /** + * logger to log to. + */ + private static Logger LOG = Logger.getLogger(NanoHTTPD.class.getName()); + + /** + * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and an + * array of loaded KeyManagers. These objects must properly + * loaded/initialized by the caller. + */ + public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManager[] keyManagers) throws IOException { + SSLServerSocketFactory res = null; + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(loadedKeyStore); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(keyManagers, trustManagerFactory.getTrustManagers(), null); + res = ctx.getServerSocketFactory(); + } catch (Exception e) { + throw new IOException(e.getMessage()); } + return res; + } - public static String getHTTPTime(int days) { - Calendar calendar = Calendar.getInstance(); - SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); - dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); - calendar.add(Calendar.DAY_OF_MONTH, days); - return dateFormat.format(calendar.getTime()); + /** + * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and a + * loaded KeyManagerFactory. These objects must properly loaded/initialized + * by the caller. + */ + public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManagerFactory loadedKeyFactory) throws IOException { + SSLServerSocketFactory res = null; + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(loadedKeyStore); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(loadedKeyFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); + res = ctx.getServerSocketFactory(); + } catch (Exception e) { + throw new IOException(e.getMessage()); } + return res; } /** - * Provides rudimentary support for cookies. Doesn't support 'path', - * 'secure' nor 'httpOnly'. Feel free to improve it and/or add unsupported - * features. - * - * @author LordFokas + * Creates an SSLSocketFactory for HTTPS. Pass a KeyStore resource with your + * certificate and passphrase */ - public class CookieHandler implements Iterable<String> { + public static SSLServerSocketFactory makeSSLSocketFactory(String keyAndTrustStoreClasspathPath, char[] passphrase) throws IOException { + SSLServerSocketFactory res = null; + try { + KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); + InputStream keystoreStream = NanoHTTPD.class.getResourceAsStream(keyAndTrustStoreClasspathPath); + keystore.load(keystoreStream, passphrase); + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keystore); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keystore, passphrase); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); + res = ctx.getServerSocketFactory(); + } catch (Exception e) { + throw new IOException(e.getMessage()); + } + return res; + } - private HashMap<String, String> cookies = new HashMap<String, String>(); + private static final void safeClose(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + NanoHTTPD.LOG.log(Level.SEVERE, "Could not close", e); + } + } + } - private ArrayList<Cookie> queue = new ArrayList<Cookie>(); + private final String hostname; - public CookieHandler(Map<String, String> httpHeaders) { - String raw = httpHeaders.get("cookie"); - if (raw != null) { - String[] tokens = raw.split(";"); - for (String token : tokens) { - String[] data = token.trim().split("="); - if (data.length == 2) { - cookies.put(data[0], data[1]); - } + private final int myPort; + + private ServerSocket myServerSocket; + + private final Set<Socket> openConnections = new HashSet<Socket>(); + + private SSLServerSocketFactory sslServerSocketFactory; + + private Thread myThread; + + /** + * Pluggable strategy for asynchronously executing requests. + */ + private AsyncRunner asyncRunner; + + /** + * Pluggable strategy for creating and cleaning up temporary files. + */ + private TempFileManagerFactory tempFileManagerFactory; + + /** + * Constructs an HTTP server on given port. + */ + public NanoHTTPD(int port) { + this(null, port); + } + + // ------------------------------------------------------------------------------- + // // + // + // Threading Strategy. + // + // ------------------------------------------------------------------------------- + // // + + /** + * Constructs an HTTP server on given hostname and port. + */ + public NanoHTTPD(String hostname, int port) { + this.hostname = hostname; + this.myPort = port; + setTempFileManagerFactory(new DefaultTempFileManagerFactory()); + setAsyncRunner(new DefaultAsyncRunner()); + } + + // ------------------------------------------------------------------------------- + // // + // + // Temp file handling strategy. + // + // ------------------------------------------------------------------------------- + // // + + /** + * Forcibly closes all connections that are open. + */ + public synchronized void closeAllConnections() { + for (Socket socket : this.openConnections) { + safeClose(socket); + } + } + + /** + * Decode parameters from a URL, handing the case where a single parameter + * name might have been supplied several times, by return lists of values. + * In general these lists will contain a single element. + * + * @param parms + * original <b>NanoHTTPD</b> parameters values, as passed to the + * <code>serve()</code> method. + * @return a map of <code>String</code> (parameter name) to + * <code>List<String></code> (a list of the values supplied). + */ + protected Map<String, List<String>> decodeParameters(Map<String, String> parms) { + return this.decodeParameters(parms.get(NanoHTTPD.QUERY_STRING_PARAMETER)); + } + + /** + * Decode parameters from a URL, handing the case where a single parameter + * name might have been supplied several times, by return lists of values. + * In general these lists will contain a single element. + * + * @param queryString + * a query string pulled from the URL. + * @return a map of <code>String</code> (parameter name) to + * <code>List<String></code> (a list of the values supplied). + */ + protected Map<String, List<String>> decodeParameters(String queryString) { + Map<String, List<String>> parms = new HashMap<String, List<String>>(); + if (queryString != null) { + StringTokenizer st = new StringTokenizer(queryString, "&"); + while (st.hasMoreTokens()) { + String e = st.nextToken(); + int sep = e.indexOf('='); + String propertyName = sep >= 0 ? decodePercent(e.substring(0, sep)).trim() : decodePercent(e).trim(); + if (!parms.containsKey(propertyName)) { + parms.put(propertyName, new ArrayList<String>()); + } + String propertyValue = sep >= 0 ? decodePercent(e.substring(sep + 1)) : null; + if (propertyValue != null) { + parms.get(propertyName).add(propertyValue); } } } + return parms; + } - @Override - public Iterator<String> iterator() { - return cookies.keySet().iterator(); + /** + * Decode percent encoded <code>String</code> values. + * + * @param str + * the percent encoded <code>String</code> + * @return expanded form of the input, for example "foo%20bar" becomes + * "foo bar" + */ + protected String decodePercent(String str) { + String decoded = null; + try { + decoded = URLDecoder.decode(str, "UTF8"); + } catch (UnsupportedEncodingException ignored) { + NanoHTTPD.LOG.log(Level.WARNING, "Encoding not supported, ignored", ignored); } + return decoded; + } - /** - * Read a cookie from the HTTP Headers. - * - * @param name - * The cookie's name. - * @return The cookie's value if it exists, null otherwise. - */ - public String read(String name) { - return cookies.get(name); - } + // ------------------------------------------------------------------------------- + // // - /** - * Sets a cookie. - * - * @param name - * The cookie's name. - * @param value - * The cookie's value. - * @param expires - * How many days until the cookie expires. - */ - public void set(String name, String value, int expires) { - queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires))); - } + public final int getListeningPort() { + return this.myServerSocket == null ? -1 : this.myServerSocket.getLocalPort(); + } - public void set(Cookie cookie) { - queue.add(cookie); + public final boolean isAlive() { + return wasStarted() && !this.myServerSocket.isClosed() && this.myThread.isAlive(); + } + + /** + * Call before start() to serve over HTTPS instead of HTTP + */ + public void makeSecure(SSLServerSocketFactory sslServerSocketFactory) { + this.sslServerSocketFactory = sslServerSocketFactory; + } + + /** + * Registers that a new connection has been set up. + * + * @param socket + * the {@link Socket} for the connection. + */ + public synchronized void registerConnection(Socket socket) { + this.openConnections.add(socket); + } + + /** + * Override this to customize the server. + * <p/> + * <p/> + * (By default, this returns a 404 "Not Found" plain text error response.) + * + * @param session + * The HTTP session + * @return HTTP response, see class Response for details + */ + public Response serve(IHTTPSession session) { + Map<String, String> files = new HashMap<String, String>(); + Method method = session.getMethod(); + if (Method.PUT.equals(method) || Method.POST.equals(method)) { + try { + session.parseBody(files); + } catch (IOException ioe) { + return new Response(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); + } catch (ResponseException re) { + return new Response(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage()); + } } - /** - * Set a cookie with an expiration date from a month ago, effectively - * deleting it on the client side. - * - * @param name - * The cookie name. - */ - public void delete(String name) { - set(name, "-delete-", -30); + Map<String, String> parms = session.getParms(); + parms.put(NanoHTTPD.QUERY_STRING_PARAMETER, session.getQueryParameterString()); + return serve(session.getUri(), method, session.getHeaders(), parms, files); + } + + /** + * Override this to customize the server. + * <p/> + * <p/> + * (By default, this returns a 404 "Not Found" plain text error response.) + * + * @param uri + * Percent-decoded URI without parameters, for example + * "/index.cgi" + * @param method + * "GET", "POST" etc. + * @param parms + * Parsed, percent decoded parameters from URI and, in case of + * POST, data. + * @param headers + * Header entries, percent decoded + * @return HTTP response, see class Response for details + */ + @Deprecated + public Response serve(String uri, Method method, Map<String, String> headers, Map<String, String> parms, Map<String, String> files) { + return new Response(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not Found"); + } + + /** + * Pluggable strategy for asynchronously executing requests. + * + * @param asyncRunner + * new strategy for handling threads. + */ + public void setAsyncRunner(AsyncRunner asyncRunner) { + this.asyncRunner = asyncRunner; + } + + /** + * Pluggable strategy for creating and cleaning up temporary files. + * + * @param tempFileManagerFactory + * new strategy for handling temp files. + */ + public void setTempFileManagerFactory(TempFileManagerFactory tempFileManagerFactory) { + this.tempFileManagerFactory = tempFileManagerFactory; + } + + /** + * Start the server. + * + * @throws IOException + * if the socket is in use. + */ + public void start() throws IOException { + if (this.sslServerSocketFactory != null) { + SSLServerSocket ss = (SSLServerSocket) this.sslServerSocketFactory.createServerSocket(); + ss.setNeedClientAuth(false); + this.myServerSocket = ss; + } else { + this.myServerSocket = new ServerSocket(); } + this.myServerSocket.setReuseAddress(true); + this.myServerSocket.bind(this.hostname != null ? new InetSocketAddress(this.hostname, this.myPort) : new InetSocketAddress(this.myPort)); - /** - * Internally used by the webserver to add all queued cookies into the - * Response's HTTP Headers. - * - * @param response - * The Response object to which headers the queued cookies - * will be added. - */ - public void unloadQueue(Response response) { - for (Cookie cookie : queue) { - response.addHeader("Set-Cookie", cookie.getHTTPHeader()); + this.myThread = new Thread(new Runnable() { + + @Override + public void run() { + do { + try { + final Socket finalAccept = NanoHTTPD.this.myServerSocket.accept(); + registerConnection(finalAccept); + finalAccept.setSoTimeout(NanoHTTPD.SOCKET_READ_TIMEOUT); + final InputStream inputStream = finalAccept.getInputStream(); + NanoHTTPD.this.asyncRunner.exec(new Runnable() { + + @Override + public void run() { + OutputStream outputStream = null; + try { + outputStream = finalAccept.getOutputStream(); + TempFileManager tempFileManager = NanoHTTPD.this.tempFileManagerFactory.create(); + HTTPSession session = new HTTPSession(tempFileManager, inputStream, outputStream, finalAccept.getInetAddress()); + while (!finalAccept.isClosed()) { + session.execute(); + } + } catch (Exception e) { + // When the socket is closed by the client, + // we throw our own SocketException + // to break the "keep alive" loop above. If + // the exception was anything other + // than the expected SocketException OR a + // SocketTimeoutException, print the + // stacktrace + if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage())) && !(e instanceof SocketTimeoutException)) { + NanoHTTPD.LOG.log(Level.FINE, "Communication with the client broken", e); + } + } finally { + safeClose(outputStream); + safeClose(inputStream); + safeClose(finalAccept); + unRegisterConnection(finalAccept); + } + } + }); + } catch (IOException e) { + NanoHTTPD.LOG.log(Level.FINE, "Communication with the client broken", e); + } + } while (!NanoHTTPD.this.myServerSocket.isClosed()); + } + }); + this.myThread.setDaemon(true); + this.myThread.setName("NanoHttpd Main Listener"); + this.myThread.start(); + } + + /** + * Stop the server. + */ + public void stop() { + try { + safeClose(this.myServerSocket); + closeAllConnections(); + if (this.myThread != null) { + this.myThread.join(); } + } catch (Exception e) { + NanoHTTPD.LOG.log(Level.SEVERE, "Could not stop all connections", e); } } + + /** + * Registers that a connection has been closed + * + * @param socket + * the {@link Socket} for the connection. + */ + public synchronized void unRegisterConnection(Socket socket) { + this.openConnections.remove(socket); + } + + public final boolean wasStarted() { + return this.myServerSocket != null && this.myThread != null; + } } diff --git a/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPlugin.java b/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPlugin.java index 92640a9..823dd92 100644 --- a/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPlugin.java +++ b/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPlugin.java @@ -33,7 +33,6 @@ package fi.iki.elonen; * #L% */ -import static fi.iki.elonen.NanoHTTPD.MIME_HTML; import static fi.iki.elonen.NanoHTTPD.Response.Status.OK; import java.io.BufferedReader; @@ -59,11 +58,7 @@ public class MarkdownWebServerPlugin implements WebServerPlugin { private final PegDownProcessor processor; public MarkdownWebServerPlugin() { - processor = new PegDownProcessor(); - } - - @Override - public void initialize(Map<String, String> commandLineOptions) { + this.processor = new PegDownProcessor(); } @Override @@ -73,9 +68,7 @@ public class MarkdownWebServerPlugin implements WebServerPlugin { } @Override - public NanoHTTPD.Response serveFile(String uri, Map<String, String> headers, NanoHTTPD.IHTTPSession session, File file, String mimeType) { - String markdownSource = readSource(file); - return markdownSource == null ? null : new NanoHTTPD.Response(OK, MIME_HTML, processor.markdownToHtml(markdownSource)); + public void initialize(Map<String, String> commandLineOptions) { } private String readSource(File file) { @@ -95,7 +88,7 @@ public class MarkdownWebServerPlugin implements WebServerPlugin { reader.close(); return sb.toString(); } catch (Exception e) { - LOG.log(Level.SEVERE, "could not read source", e); + MarkdownWebServerPlugin.LOG.log(Level.SEVERE, "could not read source", e); return null; } finally { try { @@ -106,8 +99,14 @@ public class MarkdownWebServerPlugin implements WebServerPlugin { reader.close(); } } catch (IOException ignored) { - LOG.log(Level.FINEST, "close failed", ignored); + MarkdownWebServerPlugin.LOG.log(Level.FINEST, "close failed", ignored); } } } + + @Override + public NanoHTTPD.Response serveFile(String uri, Map<String, String> headers, NanoHTTPD.IHTTPSession session, File file, String mimeType) { + String markdownSource = readSource(file); + return markdownSource == null ? null : new NanoHTTPD.Response(OK, NanoHTTPD.MIME_HTML, this.processor.markdownToHtml(markdownSource)); + } } diff --git a/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPluginInfo.java b/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPluginInfo.java index d7f1c4c..29cdc1e 100644 --- a/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPluginInfo.java +++ b/markdown-plugin/src/main/java/fi/iki/elonen/MarkdownWebServerPluginInfo.java @@ -39,16 +39,16 @@ package fi.iki.elonen; public class MarkdownWebServerPluginInfo implements WebServerPluginInfo { @Override - public String[] getMimeTypes() { + public String[] getIndexFilesForMimeType(String mime) { return new String[]{ - "text/markdown" + "index.md" }; } @Override - public String[] getIndexFilesForMimeType(String mime) { + public String[] getMimeTypes() { return new String[]{ - "index.md" + "text/markdown" }; } diff --git a/webserver/src/main/java/fi/iki/elonen/InternalRewrite.java b/webserver/src/main/java/fi/iki/elonen/InternalRewrite.java index f5dcd91..3d4ca76 100644 --- a/webserver/src/main/java/fi/iki/elonen/InternalRewrite.java +++ b/webserver/src/main/java/fi/iki/elonen/InternalRewrite.java @@ -52,11 +52,11 @@ public class InternalRewrite extends Response { this.uri = uri; } - public String getUri() { - return uri; + public Map<String, String> getHeaders() { + return this.headers; } - public Map<String, String> getHeaders() { - return headers; + public String getUri() { + return this.uri; } } diff --git a/webserver/src/main/java/fi/iki/elonen/ServerRunner.java b/webserver/src/main/java/fi/iki/elonen/ServerRunner.java index 2a4c699..bfc831c 100644 --- a/webserver/src/main/java/fi/iki/elonen/ServerRunner.java +++ b/webserver/src/main/java/fi/iki/elonen/ServerRunner.java @@ -44,14 +44,6 @@ public class ServerRunner { */ private static Logger LOG = Logger.getLogger(ServerRunner.class.getName()); - public static <T extends NanoHTTPD> void run(Class<T> serverClass) { - try { - executeInstance((NanoHTTPD) serverClass.newInstance()); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Cound nor create server", e); - } - } - public static void executeInstance(NanoHTTPD server) { try { server.start(); @@ -70,4 +62,12 @@ public class ServerRunner { server.stop(); System.out.println("Server stopped.\n"); } + + public static <T extends NanoHTTPD> void run(Class<T> serverClass) { + try { + executeInstance(serverClass.newInstance()); + } catch (Exception e) { + ServerRunner.LOG.log(Level.SEVERE, "Cound nor create server", e); + } + } } diff --git a/webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java b/webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java index 147bbf9..d485fb1 100644 --- a/webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java +++ b/webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java @@ -122,33 +122,6 @@ public class SimpleWebServer extends NanoHTTPD { private static Map<String, WebServerPlugin> mimeTypeHandlers = new HashMap<String, WebServerPlugin>(); - private final boolean quiet; - - protected List<File> rootDirs; - - public SimpleWebServer(String host, int port, File wwwroot, boolean quiet) { - super(host, port); - this.quiet = quiet; - this.rootDirs = new ArrayList<File>(); - this.rootDirs.add(wwwroot); - - this.init(); - } - - public SimpleWebServer(String host, int port, List<File> wwwroots, boolean quiet) { - super(host, port); - this.quiet = quiet; - this.rootDirs = new ArrayList<File>(wwwroots); - - this.init(); - } - - /** - * Used to initialize and customize the server. - */ - public void init() { - } - /** * Starts as a standalone file server and waits for Enter. */ @@ -172,7 +145,7 @@ public class SimpleWebServer extends NanoHTTPD { } else if (args[i].equalsIgnoreCase("-d") || args[i].equalsIgnoreCase("--dir")) { rootDirs.add(new File(args[i + 1]).getAbsoluteFile()); } else if (args[i].equalsIgnoreCase("--licence")) { - System.out.println(LICENCE + "\n"); + System.out.println(SimpleWebServer.LICENCE + "\n"); } else if (args[i].startsWith("-X:")) { int dot = args[i].indexOf('='); if (dot > 0) { @@ -234,15 +207,64 @@ public class SimpleWebServer extends NanoHTTPD { int dot = filename.lastIndexOf('.'); if (dot >= 0) { String extension = filename.substring(dot + 1).toLowerCase(); - MIME_TYPES.put(extension, mimeType); + SimpleWebServer.MIME_TYPES.put(extension, mimeType); } } - INDEX_FILE_NAMES.addAll(Arrays.asList(indexFiles)); + SimpleWebServer.INDEX_FILE_NAMES.addAll(Arrays.asList(indexFiles)); } - mimeTypeHandlers.put(mimeType, plugin); + SimpleWebServer.mimeTypeHandlers.put(mimeType, plugin); plugin.initialize(commandLineOptions); } + private final boolean quiet; + + protected List<File> rootDirs; + + public SimpleWebServer(String host, int port, File wwwroot, boolean quiet) { + super(host, port); + this.quiet = quiet; + this.rootDirs = new ArrayList<File>(); + this.rootDirs.add(wwwroot); + + init(); + } + + public SimpleWebServer(String host, int port, List<File> wwwroots, boolean quiet) { + super(host, port); + this.quiet = quiet; + this.rootDirs = new ArrayList<File>(wwwroots); + + init(); + } + + private boolean canServeUri(String uri, File homeDir) { + boolean canServeUri; + File f = new File(homeDir, uri); + canServeUri = f.exists(); + if (!canServeUri) { + String mimeTypeForFile = getMimeTypeForFile(uri); + WebServerPlugin plugin = SimpleWebServer.mimeTypeHandlers.get(mimeTypeForFile); + if (plugin != null) { + canServeUri = plugin.canServeUri(uri, homeDir); + } + } + return canServeUri; + } + + // Announce that the file server accepts partial content requests + private Response createResponse(Response.Status status, String mimeType, InputStream message) { + Response res = new Response(status, mimeType, message); + res.addHeader("Accept-Ranges", "bytes"); + return res; + } + + // Announce that the file server accepts partial content requests + private Response createResponse(Response.Status status, String mimeType, String message) { + Response res = new Response(status, mimeType, message); + res.addHeader("Accept-Ranges", "bytes"); + return res; + } + /** * URL-encodes everything between "/"-characters. Encodes spaces as '%20' * instead of '+'. @@ -252,11 +274,11 @@ public class SimpleWebServer extends NanoHTTPD { StringTokenizer st = new StringTokenizer(uri, "/ ", true); while (st.hasMoreTokens()) { String tok = st.nextToken(); - if (tok.equals("/")) + if (tok.equals("/")) { newUri += "/"; - else if (tok.equals(" ")) + } else if (tok.equals(" ")) { newUri += "%20"; - else { + } else { try { newUri += URLEncoder.encode(tok, "UTF-8"); } catch (UnsupportedEncodingException ignored) { @@ -266,33 +288,111 @@ public class SimpleWebServer extends NanoHTTPD { return newUri; } - public Response serve(IHTTPSession session) { - Map<String, String> header = session.getHeaders(); - Map<String, String> parms = session.getParms(); - String uri = session.getUri(); + private String findIndexFileInDirectory(File directory) { + for (String fileName : SimpleWebServer.INDEX_FILE_NAMES) { + File indexFile = new File(directory, fileName); + if (indexFile.exists()) { + return fileName; + } + } + return null; + } - if (!quiet) { - System.out.println(session.getMethod() + " '" + uri + "' "); + protected Response getForbiddenResponse(String s) { + return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: " + s); + } - Iterator<String> e = header.keySet().iterator(); - while (e.hasNext()) { - String value = e.next(); - System.out.println(" HDR: '" + value + "' = '" + header.get(value) + "'"); - } - e = parms.keySet().iterator(); - while (e.hasNext()) { - String value = e.next(); - System.out.println(" PRM: '" + value + "' = '" + parms.get(value) + "'"); + protected Response getInternalErrorResponse(String s) { + return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "INTERNAL ERROR: " + s); + } + + // Get MIME type from file name extension, if possible + private String getMimeTypeForFile(String uri) { + int dot = uri.lastIndexOf('.'); + String mime = null; + if (dot >= 0) { + mime = SimpleWebServer.MIME_TYPES.get(uri.substring(dot + 1).toLowerCase()); + } + return mime == null ? SimpleWebServer.MIME_DEFAULT_BINARY : mime; + } + + protected Response getNotFoundResponse() { + return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Error 404, file not found."); + } + + /** + * Used to initialize and customize the server. + */ + public void init() { + } + + protected String listDirectory(String uri, File f) { + String heading = "Directory " + uri; + StringBuilder msg = + new StringBuilder("<html><head><title>" + heading + "</title><style><!--\n" + "span.dirname { font-weight: bold; }\n" + "span.filesize { font-size: 75%; }\n" + + "// -->\n" + "</style>" + "</head><body><h1>" + heading + "</h1>"); + + String up = null; + if (uri.length() > 1) { + String u = uri.substring(0, uri.length() - 1); + int slash = u.lastIndexOf('/'); + if (slash >= 0 && slash < u.length()) { + up = uri.substring(0, slash + 1); } } - for (File homeDir : rootDirs) { - // Make sure we won't die of an exception later - if (!homeDir.isDirectory()) { - return getInternalErrorResponse("given path is not a directory (" + homeDir + ")."); + List<String> files = Arrays.asList(f.list(new FilenameFilter() { + + @Override + public boolean accept(File dir, String name) { + return new File(dir, name).isFile(); + } + })); + Collections.sort(files); + List<String> directories = Arrays.asList(f.list(new FilenameFilter() { + + @Override + public boolean accept(File dir, String name) { + return new File(dir, name).isDirectory(); + } + })); + Collections.sort(directories); + if (up != null || directories.size() + files.size() > 0) { + msg.append("<ul>"); + if (up != null || directories.size() > 0) { + msg.append("<section class=\"directories\">"); + if (up != null) { + msg.append("<li><a rel=\"directory\" href=\"").append(up).append("\"><span class=\"dirname\">..</span></a></b></li>"); + } + for (String directory : directories) { + String dir = directory + "/"; + msg.append("<li><a rel=\"directory\" href=\"").append(encodeUri(uri + dir)).append("\"><span class=\"dirname\">").append(dir) + .append("</span></a></b></li>"); + } + msg.append("</section>"); } + if (files.size() > 0) { + msg.append("<section class=\"files\">"); + for (String file : files) { + msg.append("<li><a href=\"").append(encodeUri(uri + file)).append("\"><span class=\"filename\">").append(file).append("</span></a>"); + File curFile = new File(f, file); + long len = curFile.length(); + msg.append(" <span class=\"filesize\">("); + if (len < 1024) { + msg.append(len).append(" bytes"); + } else if (len < 1024 * 1024) { + msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100).append(" KB"); + } else { + msg.append(len / (1024 * 1024)).append(".").append(len % (1024 * 1024) / 10000 % 100).append(" MB"); + } + msg.append(")</span></li>"); + } + msg.append("</section>"); + } + msg.append("</ul>"); } - return respond(Collections.unmodifiableMap(header), session, uri); + msg.append("</body></html>"); + return msg.toString(); } private Response respond(Map<String, String> headers, IHTTPSession session, String uri) { @@ -309,8 +409,8 @@ public class SimpleWebServer extends NanoHTTPD { boolean canServeUri = false; File homeDir = null; - for (int i = 0; !canServeUri && i < rootDirs.size(); i++) { - homeDir = rootDirs.get(i); + for (int i = 0; !canServeUri && i < this.rootDirs.size(); i++) { + homeDir = this.rootDirs.get(i); canServeUri = canServeUri(uri, homeDir); } if (!canServeUri) { @@ -344,7 +444,7 @@ public class SimpleWebServer extends NanoHTTPD { } String mimeTypeForFile = getMimeTypeForFile(uri); - WebServerPlugin plugin = mimeTypeHandlers.get(mimeTypeForFile); + WebServerPlugin plugin = SimpleWebServer.mimeTypeHandlers.get(mimeTypeForFile); Response response = null; if (plugin != null) { response = plugin.serveFile(uri, headers, session, f, mimeTypeForFile); @@ -358,30 +458,34 @@ public class SimpleWebServer extends NanoHTTPD { return response != null ? response : getNotFoundResponse(); } - protected Response getNotFoundResponse() { - return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Error 404, file not found."); - } + @Override + public Response serve(IHTTPSession session) { + Map<String, String> header = session.getHeaders(); + Map<String, String> parms = session.getParms(); + String uri = session.getUri(); - protected Response getForbiddenResponse(String s) { - return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: " + s); - } + if (!this.quiet) { + System.out.println(session.getMethod() + " '" + uri + "' "); - protected Response getInternalErrorResponse(String s) { - return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "INTERNAL ERROR: " + s); - } + Iterator<String> e = header.keySet().iterator(); + while (e.hasNext()) { + String value = e.next(); + System.out.println(" HDR: '" + value + "' = '" + header.get(value) + "'"); + } + e = parms.keySet().iterator(); + while (e.hasNext()) { + String value = e.next(); + System.out.println(" PRM: '" + value + "' = '" + parms.get(value) + "'"); + } + } - private boolean canServeUri(String uri, File homeDir) { - boolean canServeUri; - File f = new File(homeDir, uri); - canServeUri = f.exists(); - if (!canServeUri) { - String mimeTypeForFile = getMimeTypeForFile(uri); - WebServerPlugin plugin = mimeTypeHandlers.get(mimeTypeForFile); - if (plugin != null) { - canServeUri = plugin.canServeUri(uri, homeDir); + for (File homeDir : this.rootDirs) { + // Make sure we won't die of an exception later + if (!homeDir.isDirectory()) { + return getInternalErrorResponse("given path is not a directory (" + homeDir + ")."); } } - return canServeUri; + return respond(Collections.unmodifiableMap(header), session, uri); } /** @@ -445,9 +549,9 @@ public class SimpleWebServer extends NanoHTTPD { res.addHeader("ETag", etag); } } else { - if (etag.equals(header.get("if-none-match"))) + if (etag.equals(header.get("if-none-match"))) { res = createResponse(Response.Status.NOT_MODIFIED, mime, ""); - else { + } else { res = createResponse(Response.Status.OK, mime, new FileInputStream(file)); res.addHeader("Content-Length", "" + fileLen); res.addHeader("ETag", etag); @@ -459,107 +563,4 @@ public class SimpleWebServer extends NanoHTTPD { return res; } - - // Get MIME type from file name extension, if possible - private String getMimeTypeForFile(String uri) { - int dot = uri.lastIndexOf('.'); - String mime = null; - if (dot >= 0) { - mime = MIME_TYPES.get(uri.substring(dot + 1).toLowerCase()); - } - return mime == null ? MIME_DEFAULT_BINARY : mime; - } - - // Announce that the file server accepts partial content requests - private Response createResponse(Response.Status status, String mimeType, InputStream message) { - Response res = new Response(status, mimeType, message); - res.addHeader("Accept-Ranges", "bytes"); - return res; - } - - // Announce that the file server accepts partial content requests - private Response createResponse(Response.Status status, String mimeType, String message) { - Response res = new Response(status, mimeType, message); - res.addHeader("Accept-Ranges", "bytes"); - return res; - } - - private String findIndexFileInDirectory(File directory) { - for (String fileName : INDEX_FILE_NAMES) { - File indexFile = new File(directory, fileName); - if (indexFile.exists()) { - return fileName; - } - } - return null; - } - - protected String listDirectory(String uri, File f) { - String heading = "Directory " + uri; - StringBuilder msg = - new StringBuilder("<html><head><title>" + heading + "</title><style><!--\n" + "span.dirname { font-weight: bold; }\n" + "span.filesize { font-size: 75%; }\n" - + "// -->\n" + "</style>" + "</head><body><h1>" + heading + "</h1>"); - - String up = null; - if (uri.length() > 1) { - String u = uri.substring(0, uri.length() - 1); - int slash = u.lastIndexOf('/'); - if (slash >= 0 && slash < u.length()) { - up = uri.substring(0, slash + 1); - } - } - - List<String> files = Arrays.asList(f.list(new FilenameFilter() { - - @Override - public boolean accept(File dir, String name) { - return new File(dir, name).isFile(); - } - })); - Collections.sort(files); - List<String> directories = Arrays.asList(f.list(new FilenameFilter() { - - @Override - public boolean accept(File dir, String name) { - return new File(dir, name).isDirectory(); - } - })); - Collections.sort(directories); - if (up != null || directories.size() + files.size() > 0) { - msg.append("<ul>"); - if (up != null || directories.size() > 0) { - msg.append("<section class=\"directories\">"); - if (up != null) { - msg.append("<li><a rel=\"directory\" href=\"").append(up).append("\"><span class=\"dirname\">..</span></a></b></li>"); - } - for (String directory : directories) { - String dir = directory + "/"; - msg.append("<li><a rel=\"directory\" href=\"").append(encodeUri(uri + dir)).append("\"><span class=\"dirname\">").append(dir) - .append("</span></a></b></li>"); - } - msg.append("</section>"); - } - if (files.size() > 0) { - msg.append("<section class=\"files\">"); - for (String file : files) { - msg.append("<li><a href=\"").append(encodeUri(uri + file)).append("\"><span class=\"filename\">").append(file).append("</span></a>"); - File curFile = new File(f, file); - long len = curFile.length(); - msg.append(" <span class=\"filesize\">("); - if (len < 1024) { - msg.append(len).append(" bytes"); - } else if (len < 1024 * 1024) { - msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100).append(" KB"); - } else { - msg.append(len / (1024 * 1024)).append(".").append(len % (1024 * 1024) / 10000 % 100).append(" MB"); - } - msg.append(")</span></li>"); - } - msg.append("</section>"); - } - msg.append("</ul>"); - } - msg.append("</body></html>"); - return msg.toString(); - } } diff --git a/webserver/src/main/java/fi/iki/elonen/WebServerPlugin.java b/webserver/src/main/java/fi/iki/elonen/WebServerPlugin.java index cdfc3ff..8b490d3 100644 --- a/webserver/src/main/java/fi/iki/elonen/WebServerPlugin.java +++ b/webserver/src/main/java/fi/iki/elonen/WebServerPlugin.java @@ -43,9 +43,9 @@ import fi.iki.elonen.NanoHTTPD.IHTTPSession; */ public interface WebServerPlugin { - void initialize(Map<String, String> commandLineOptions); - boolean canServeUri(String uri, File rootDir); + void initialize(Map<String, String> commandLineOptions); + NanoHTTPD.Response serveFile(String uri, Map<String, String> headers, IHTTPSession session, File file, String mimeType); } diff --git a/webserver/src/main/java/fi/iki/elonen/WebServerPluginInfo.java b/webserver/src/main/java/fi/iki/elonen/WebServerPluginInfo.java index c1ef6e8..0fe5f4e 100644 --- a/webserver/src/main/java/fi/iki/elonen/WebServerPluginInfo.java +++ b/webserver/src/main/java/fi/iki/elonen/WebServerPluginInfo.java @@ -38,9 +38,9 @@ package fi.iki.elonen; */ public interface WebServerPluginInfo { - String[] getMimeTypes(); - String[] getIndexFilesForMimeType(String mime); + String[] getMimeTypes(); + WebServerPlugin getWebServerPlugin(String mimeType); } diff --git a/websocket/src/main/java/fi/iki/elonen/NanoWebSocketServer.java b/websocket/src/main/java/fi/iki/elonen/NanoWebSocketServer.java index 8163c10..8fc38f7 100644 --- a/websocket/src/main/java/fi/iki/elonen/NanoWebSocketServer.java +++ b/websocket/src/main/java/fi/iki/elonen/NanoWebSocketServer.java @@ -54,133 +54,6 @@ import fi.iki.elonen.NanoWebSocketServer.WebSocketFrame.OpCode; public abstract class NanoWebSocketServer extends NanoHTTPD { - /** - * logger to log to. - */ - private static Logger LOG = Logger.getLogger(NanoWebSocketServer.class.getName()); - - public static final String HEADER_UPGRADE = "upgrade"; - - public static final String HEADER_UPGRADE_VALUE = "websocket"; - - public static final String HEADER_CONNECTION = "connection"; - - public static final String HEADER_CONNECTION_VALUE = "Upgrade"; - - public static final String HEADER_WEBSOCKET_VERSION = "sec-websocket-version"; - - public static final String HEADER_WEBSOCKET_VERSION_VALUE = "13"; - - public static final String HEADER_WEBSOCKET_KEY = "sec-websocket-key"; - - public static final String HEADER_WEBSOCKET_ACCEPT = "sec-websocket-accept"; - - public static final String HEADER_WEBSOCKET_PROTOCOL = "sec-websocket-protocol"; - - private final static String WEBSOCKET_KEY_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - - public NanoWebSocketServer(int port) { - super(port); - } - - public NanoWebSocketServer(String hostname, int port) { - super(hostname, port); - } - - @Override - public Response serve(final IHTTPSession session) { - Map<String, String> headers = session.getHeaders(); - if (isWebsocketRequested(session)) { - if (!HEADER_WEBSOCKET_VERSION_VALUE.equalsIgnoreCase(headers.get(HEADER_WEBSOCKET_VERSION))) { - return new Response(Response.Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Invalid Websocket-Version " + headers.get(HEADER_WEBSOCKET_VERSION)); - } - - if (!headers.containsKey(HEADER_WEBSOCKET_KEY)) { - return new Response(Response.Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Missing Websocket-Key"); - } - - WebSocket webSocket = openWebSocket(session); - Response handshakeResponse = webSocket.getHandshakeResponse(); - try { - handshakeResponse.addHeader(HEADER_WEBSOCKET_ACCEPT, makeAcceptKey(headers.get(HEADER_WEBSOCKET_KEY))); - } catch (NoSuchAlgorithmException e) { - return new Response(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "The SHA-1 Algorithm required for websockets is not available on the server."); - } - - if (headers.containsKey(HEADER_WEBSOCKET_PROTOCOL)) { - handshakeResponse.addHeader(HEADER_WEBSOCKET_PROTOCOL, headers.get(HEADER_WEBSOCKET_PROTOCOL).split(",")[0]); - } - - return handshakeResponse; - } else { - return super.serve(session); - } - } - - protected boolean isWebsocketRequested(IHTTPSession session) { - Map<String, String> headers = session.getHeaders(); - String upgrade = headers.get(HEADER_UPGRADE); - boolean isCorrectConnection = isWebSocketConnectionHeader(headers); - boolean isUpgrade = HEADER_UPGRADE_VALUE.equalsIgnoreCase(upgrade); - return (isUpgrade && isCorrectConnection); - } - - private boolean isWebSocketConnectionHeader(Map<String, String> headers) { - String connection = headers.get(HEADER_CONNECTION); - return (connection != null && connection.toLowerCase().contains(HEADER_CONNECTION_VALUE.toLowerCase())); - } - - public WebSocket openWebSocket(IHTTPSession handshake) { - return new WebSocket(handshake); - } - - public static String makeAcceptKey(String key) throws NoSuchAlgorithmException { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - String text = key + WEBSOCKET_KEY_MAGIC; - md.update(text.getBytes(), 0, text.length()); - byte[] sha1hash = md.digest(); - return encodeBase64(sha1hash); - } - - private final static char[] ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray(); - - /** - * Translates the specified byte array into Base64 string. - * <p> - * Android has android.util.Base64, sun has sun.misc.Base64Encoder, Java 8 - * hast java.util.Base64, I have this from stackoverflow: - * http://stackoverflow.com/a/4265472 - * </p> - * - * @param buf - * the byte array (not null) - * @return the translated Base64 string (not null) - */ - private static String encodeBase64(byte[] buf) { - int size = buf.length; - char[] ar = new char[((size + 2) / 3) * 4]; - int a = 0; - int i = 0; - while (i < size) { - byte b0 = buf[i++]; - byte b1 = (i < size) ? buf[i++] : 0; - byte b2 = (i < size) ? buf[i++] : 0; - - int mask = 0x3F; - ar[a++] = ALPHABET[(b0 >> 2) & mask]; - ar[a++] = ALPHABET[((b0 << 4) | ((b1 & 0xFF) >> 4)) & mask]; - ar[a++] = ALPHABET[((b1 << 2) | ((b2 & 0xFF) >> 6)) & mask]; - ar[a++] = ALPHABET[b2 & mask]; - } - switch (size % 3) { - case 1: - ar[--a] = '='; - case 2: - ar[--a] = '='; - } - return new String(ar); - } - public static enum State { UNCONNECTED, CONNECTING, @@ -191,13 +64,13 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { public class WebSocket { - private InputStream in; + private final InputStream in; private OutputStream out; private WebSocketFrame.OpCode continuousOpCode = null; - private List<WebSocketFrame> continuousFrames = new LinkedList<WebSocketFrame>(); + private final List<WebSocketFrame> continuousFrames = new LinkedList<WebSocketFrame>(); private State state = State.UNCONNECTED; @@ -208,9 +81,9 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { @Override protected void send(OutputStream out) { WebSocket.this.out = out; - state = State.CONNECTING; + WebSocket.this.state = State.CONNECTING; super.send(out); - state = State.OPEN; + WebSocket.this.state = State.OPEN; readWebsocket(); } }; @@ -219,55 +92,50 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { this.handshakeRequest = handshakeRequest; this.in = handshakeRequest.getInputStream(); - handshakeResponse.addHeader(HEADER_UPGRADE, HEADER_UPGRADE_VALUE); - handshakeResponse.addHeader(HEADER_CONNECTION, HEADER_CONNECTION_VALUE); + this.handshakeResponse.addHeader(NanoWebSocketServer.HEADER_UPGRADE, NanoWebSocketServer.HEADER_UPGRADE_VALUE); + this.handshakeResponse.addHeader(NanoWebSocketServer.HEADER_CONNECTION, NanoWebSocketServer.HEADER_CONNECTION_VALUE); } - public NanoHTTPD.IHTTPSession getHandshakeRequest() { - return handshakeRequest; + public void close(CloseCode code, String reason) throws IOException { + State oldState = this.state; + this.state = State.CLOSING; + if (oldState == State.OPEN) { + sendFrame(new CloseFrame(code, reason)); + } else { + doClose(code, reason, false); + } } - public NanoHTTPD.Response getHandshakeResponse() { - return handshakeResponse; + private void doClose(CloseCode code, String reason, boolean initiatedByRemote) { + if (this.state == State.CLOSED) { + return; + } + if (this.in != null) { + try { + this.in.close(); + } catch (IOException e) { + NanoWebSocketServer.LOG.log(Level.FINE, "close failed", e); + } + } + if (this.out != null) { + try { + this.out.close(); + } catch (IOException e) { + NanoWebSocketServer.LOG.log(Level.FINE, "close failed", e); + } + } + this.state = State.CLOSED; + onClose(this, code, reason, initiatedByRemote); } // --------------------------------IO-------------------------------------- - private void readWebsocket() { - try { - while (state == State.OPEN) { - handleWebsocketFrame(WebSocketFrame.read(in)); - } - } catch (CharacterCodingException e) { - onException(this, e); - doClose(CloseCode.InvalidFramePayloadData, e.toString(), false); - } catch (IOException e) { - onException(this, e); - if (e instanceof WebSocketException) { - doClose(((WebSocketException) e).getCode(), ((WebSocketException) e).getReason(), false); - } - } finally { - doClose(CloseCode.InternalServerError, "Handler terminated without closing the connection.", false); - } + public NanoHTTPD.IHTTPSession getHandshakeRequest() { + return this.handshakeRequest; } - private void handleWebsocketFrame(WebSocketFrame frame) throws IOException { - onFrameReceived(frame); - if (frame.getOpCode() == OpCode.Close) { - handleCloseFrame(frame); - } else if (frame.getOpCode() == OpCode.Ping) { - sendFrame(new WebSocketFrame(OpCode.Pong, true, frame.getBinaryPayload())); - } else if (frame.getOpCode() == OpCode.Pong) { - onPong(this, frame); - } else if (!frame.isFin() || frame.getOpCode() == OpCode.Continuation) { - handleFrameFragment(frame); - } else if (continuousOpCode != null) { - throw new WebSocketException(CloseCode.ProtocolError, "Continuous frame sequence not completed."); - } else if (frame.getOpCode() == OpCode.Text || frame.getOpCode() == OpCode.Binary) { - onMessage(this, frame); - } else { - throw new WebSocketException(CloseCode.ProtocolError, "Non control or continuous frame expected."); - } + public NanoHTTPD.Response getHandshakeResponse() { + return this.handshakeResponse; } private void handleCloseFrame(WebSocketFrame frame) throws IOException { @@ -277,13 +145,13 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { code = ((CloseFrame) frame).getCloseCode(); reason = ((CloseFrame) frame).getCloseReason(); } - if (state == State.CLOSING) { + if (this.state == State.CLOSING) { // Answer for my requested close doClose(code, reason, false); } else { // Answer close request from other endpoint and close self - State oldState = state; - state = State.CLOSING; + State oldState = this.state; + this.state = State.CLOSING; if (oldState == State.OPEN) { sendFrame(new CloseFrame(code, reason)); } @@ -294,63 +162,73 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { private void handleFrameFragment(WebSocketFrame frame) throws IOException { if (frame.getOpCode() != OpCode.Continuation) { // First - if (continuousOpCode != null) { + if (this.continuousOpCode != null) { throw new WebSocketException(CloseCode.ProtocolError, "Previous continuous frame sequence not completed."); } - continuousOpCode = frame.getOpCode(); - continuousFrames.clear(); - continuousFrames.add(frame); + this.continuousOpCode = frame.getOpCode(); + this.continuousFrames.clear(); + this.continuousFrames.add(frame); } else if (frame.isFin()) { // Last - if (continuousOpCode == null) { + if (this.continuousOpCode == null) { throw new WebSocketException(CloseCode.ProtocolError, "Continuous frame sequence was not started."); } - onMessage(this, new WebSocketFrame(continuousOpCode, continuousFrames)); - continuousOpCode = null; - continuousFrames.clear(); - } else if (continuousOpCode == null) { + onMessage(this, new WebSocketFrame(this.continuousOpCode, this.continuousFrames)); + this.continuousOpCode = null; + this.continuousFrames.clear(); + } else if (this.continuousOpCode == null) { // Unexpected throw new WebSocketException(CloseCode.ProtocolError, "Continuous frame sequence was not started."); } else { // Intermediate - continuousFrames.add(frame); + this.continuousFrames.add(frame); } } - public synchronized void sendFrame(WebSocketFrame frame) throws IOException { - onSendFrame(frame); - frame.write(out); + private void handleWebsocketFrame(WebSocketFrame frame) throws IOException { + onFrameReceived(frame); + if (frame.getOpCode() == OpCode.Close) { + handleCloseFrame(frame); + } else if (frame.getOpCode() == OpCode.Ping) { + sendFrame(new WebSocketFrame(OpCode.Pong, true, frame.getBinaryPayload())); + } else if (frame.getOpCode() == OpCode.Pong) { + onPong(this, frame); + } else if (!frame.isFin() || frame.getOpCode() == OpCode.Continuation) { + handleFrameFragment(frame); + } else if (this.continuousOpCode != null) { + throw new WebSocketException(CloseCode.ProtocolError, "Continuous frame sequence not completed."); + } else if (frame.getOpCode() == OpCode.Text || frame.getOpCode() == OpCode.Binary) { + onMessage(this, frame); + } else { + throw new WebSocketException(CloseCode.ProtocolError, "Non control or continuous frame expected."); + } } // --------------------------------Close----------------------------------- - private void doClose(CloseCode code, String reason, boolean initiatedByRemote) { - if (state == State.CLOSED) { - return; - } - if (in != null) { - try { - in.close(); - } catch (IOException e) { - LOG.log(Level.FINE, "close failed", e); - } - } - if (out != null) { - try { - out.close(); - } catch (IOException e) { - LOG.log(Level.FINE, "close failed", e); - } - } - state = State.CLOSED; - onClose(this, code, reason, initiatedByRemote); + public void ping(byte[] payload) throws IOException { + sendFrame(new WebSocketFrame(OpCode.Ping, true, payload)); } // --------------------------------Public // Facade--------------------------- - public void ping(byte[] payload) throws IOException { - sendFrame(new WebSocketFrame(OpCode.Ping, true, payload)); + private void readWebsocket() { + try { + while (this.state == State.OPEN) { + handleWebsocketFrame(WebSocketFrame.read(this.in)); + } + } catch (CharacterCodingException e) { + onException(this, e); + doClose(CloseCode.InvalidFramePayloadData, e.toString(), false); + } catch (IOException e) { + onException(this, e); + if (e instanceof WebSocketException) { + doClose(((WebSocketException) e).getCode(), ((WebSocketException) e).getReason(), false); + } + } finally { + doClose(CloseCode.InternalServerError, "Handler terminated without closing the connection.", false); + } } public void send(byte[] payload) throws IOException { @@ -361,19 +239,196 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { sendFrame(new WebSocketFrame(OpCode.Text, true, payload)); } - public void close(CloseCode code, String reason) throws IOException { - State oldState = state; - state = State.CLOSING; - if (oldState == State.OPEN) { - sendFrame(new CloseFrame(code, reason)); - } else { - doClose(code, reason, false); - } + public synchronized void sendFrame(WebSocketFrame frame) throws IOException { + onSendFrame(frame); + frame.write(this.out); + } + } + + public static class WebSocketException extends IOException { + + private static final long serialVersionUID = 1L; + + private final CloseCode code; + + private final String reason; + + public WebSocketException(CloseCode code, String reason) { + this(code, reason, null); + } + + public WebSocketException(CloseCode code, String reason, Exception cause) { + super(code + ": " + reason, cause); + this.code = code; + this.reason = reason; + } + + public WebSocketException(Exception cause) { + this(CloseCode.InternalServerError, cause.toString(), cause); + } + + public CloseCode getCode() { + return this.code; + } + + public String getReason() { + return this.reason; } } public static class WebSocketFrame { + public static enum CloseCode { + NormalClosure(1000), + GoingAway(1001), + ProtocolError(1002), + UnsupportedData(1003), + NoStatusRcvd(1005), + AbnormalClosure(1006), + InvalidFramePayloadData(1007), + PolicyViolation(1008), + MessageTooBig(1009), + MandatoryExt(1010), + InternalServerError(1011), + TLSHandshake(1015); + + public static CloseCode find(int value) { + for (CloseCode code : values()) { + if (code.getValue() == value) { + return code; + } + } + return null; + } + + private final int code; + + private CloseCode(int code) { + this.code = code; + } + + public int getValue() { + return this.code; + } + } + + public static class CloseFrame extends WebSocketFrame { + + private static byte[] generatePayload(CloseCode code, String closeReason) throws CharacterCodingException { + if (code != null) { + byte[] reasonBytes = text2Binary(closeReason); + byte[] payload = new byte[reasonBytes.length + 2]; + payload[0] = (byte) (code.getValue() >> 8 & 0xFF); + payload[1] = (byte) (code.getValue() & 0xFF); + System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); + return payload; + } else { + return new byte[0]; + } + } + + private CloseCode _closeCode; + + private String _closeReason; + + public CloseFrame(CloseCode code, String closeReason) throws CharacterCodingException { + super(OpCode.Close, true, generatePayload(code, closeReason)); + } + + private CloseFrame(WebSocketFrame wrap) throws CharacterCodingException { + super(wrap); + assert wrap.getOpCode() == OpCode.Close; + if (wrap.getBinaryPayload().length >= 2) { + this._closeCode = CloseCode.find((wrap.getBinaryPayload()[0] & 0xFF) << 8 | wrap.getBinaryPayload()[1] & 0xFF); + this._closeReason = binary2Text(getBinaryPayload(), 2, getBinaryPayload().length - 2); + } + } + + public CloseCode getCloseCode() { + return this._closeCode; + } + + public String getCloseReason() { + return this._closeReason; + } + } + + public static enum OpCode { + Continuation(0), + Text(1), + Binary(2), + Close(8), + Ping(9), + Pong(10); + + public static OpCode find(byte value) { + for (OpCode opcode : values()) { + if (opcode.getValue() == value) { + return opcode; + } + } + return null; + } + + private final byte code; + + private OpCode(int code) { + this.code = (byte) code; + } + + public byte getValue() { + return this.code; + } + + public boolean isControlFrame() { + return this == Close || this == Ping || this == Pong; + } + } + + public static final Charset TEXT_CHARSET = Charset.forName("UTF-8"); + + public static String binary2Text(byte[] payload) throws CharacterCodingException { + return new String(payload, WebSocketFrame.TEXT_CHARSET); + } + + public static String binary2Text(byte[] payload, int offset, int length) throws CharacterCodingException { + return new String(payload, offset, length, WebSocketFrame.TEXT_CHARSET); + } + + private static int checkedRead(int read) throws IOException { + if (read < 0) { + throw new EOFException(); + } + return read; + } + + public static WebSocketFrame read(InputStream in) throws IOException { + byte head = (byte) checkedRead(in.read()); + boolean fin = (head & 0x80) != 0; + OpCode opCode = OpCode.find((byte) (head & 0x0F)); + if ((head & 0x70) != 0) { + throw new WebSocketException(CloseCode.ProtocolError, "The reserved bits (" + Integer.toBinaryString(head & 0x70) + ") must be 0."); + } + if (opCode == null) { + throw new WebSocketException(CloseCode.ProtocolError, "Received frame with reserved/unknown opcode " + (head & 0x0F) + "."); + } else if (opCode.isControlFrame() && !fin) { + throw new WebSocketException(CloseCode.ProtocolError, "Fragmented control frame."); + } + + WebSocketFrame frame = new WebSocketFrame(opCode, fin); + frame.readPayloadInfo(in); + frame.readPayload(in); + if (frame.getOpCode() == OpCode.Close) { + return new CloseFrame(frame); + } else { + return frame; + } + } + + public static byte[] text2Binary(String payload) throws CharacterCodingException { + return payload.getBytes(WebSocketFrame.TEXT_CHARSET); + } + private OpCode opCode; private boolean fin; @@ -382,6 +437,8 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { private byte[] payload; + // --------------------------------GETTERS--------------------------------- + private transient int _payloadLength; private transient String _payloadString; @@ -391,13 +448,17 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { setFin(fin); } + public WebSocketFrame(OpCode opCode, boolean fin, byte[] payload) { + this(opCode, fin, payload, null); + } + public WebSocketFrame(OpCode opCode, boolean fin, byte[] payload, byte[] maskingKey) { this(opCode, fin); setMaskingKey(maskingKey); setBinaryPayload(payload); } - public WebSocketFrame(OpCode opCode, boolean fin, byte[] payload) { + public WebSocketFrame(OpCode opCode, boolean fin, String payload) throws CharacterCodingException { this(opCode, fin, payload, null); } @@ -407,17 +468,6 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { setTextPayload(payload); } - public WebSocketFrame(OpCode opCode, boolean fin, String payload) throws CharacterCodingException { - this(opCode, fin, payload, null); - } - - public WebSocketFrame(WebSocketFrame clone) { - setOpCode(clone.getOpCode()); - setFin(clone.isFin()); - setBinaryPayload(clone.getBinaryPayload()); - setMaskingKey(clone.getMaskingKey()); - } - public WebSocketFrame(OpCode opCode, List<WebSocketFrame> fragments) throws WebSocketException { setOpCode(opCode); setFin(true); @@ -439,118 +489,108 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { setBinaryPayload(payload); } - // --------------------------------GETTERS--------------------------------- - - public OpCode getOpCode() { - return opCode; - } - - public void setOpCode(OpCode opcode) { - this.opCode = opcode; - } - - public boolean isFin() { - return fin; - } - - public void setFin(boolean fin) { - this.fin = fin; + public WebSocketFrame(WebSocketFrame clone) { + setOpCode(clone.getOpCode()); + setFin(clone.isFin()); + setBinaryPayload(clone.getBinaryPayload()); + setMaskingKey(clone.getMaskingKey()); } - public boolean isMasked() { - return maskingKey != null && maskingKey.length == 4; + public byte[] getBinaryPayload() { + return this.payload; } public byte[] getMaskingKey() { - return maskingKey; - } - - public void setMaskingKey(byte[] maskingKey) { - if (maskingKey != null && maskingKey.length != 4) { - throw new IllegalArgumentException("MaskingKey " + Arrays.toString(maskingKey) + " hasn't length 4"); - } - this.maskingKey = maskingKey; - } - - public void setUnmasked() { - setMaskingKey(null); + return this.maskingKey; } - public byte[] getBinaryPayload() { - return payload; + public OpCode getOpCode() { + return this.opCode; } - public void setBinaryPayload(byte[] payload) { - this.payload = payload; - this._payloadLength = payload.length; - this._payloadString = null; - } + // --------------------------------SERIALIZATION--------------------------- public String getTextPayload() { - if (_payloadString == null) { + if (this._payloadString == null) { try { - _payloadString = binary2Text(getBinaryPayload()); + this._payloadString = binary2Text(getBinaryPayload()); } catch (CharacterCodingException e) { throw new RuntimeException("Undetected CharacterCodingException", e); } } - return _payloadString; + return this._payloadString; } - public void setTextPayload(String payload) throws CharacterCodingException { - this.payload = text2Binary(payload); - this._payloadLength = payload.length(); - this._payloadString = payload; + public boolean isFin() { + return this.fin; } - // --------------------------------SERIALIZATION--------------------------- + public boolean isMasked() { + return this.maskingKey != null && this.maskingKey.length == 4; + } - public static WebSocketFrame read(InputStream in) throws IOException { - byte head = (byte) checkedRead(in.read()); - boolean fin = ((head & 0x80) != 0); - OpCode opCode = OpCode.find((byte) (head & 0x0F)); - if ((head & 0x70) != 0) { - throw new WebSocketException(CloseCode.ProtocolError, "The reserved bits (" + Integer.toBinaryString(head & 0x70) + ") must be 0."); + private String payloadToString() { + if (this.payload == null) { + return "null"; + } else { + final StringBuilder sb = new StringBuilder(); + sb.append('[').append(this.payload.length).append("b] "); + if (getOpCode() == OpCode.Text) { + String text = getTextPayload(); + if (text.length() > 100) { + sb.append(text.substring(0, 100)).append("..."); + } else { + sb.append(text); + } + } else { + sb.append("0x"); + for (int i = 0; i < Math.min(this.payload.length, 50); ++i) { + sb.append(Integer.toHexString(this.payload[i] & 0xFF)); + } + if (this.payload.length > 50) { + sb.append("..."); + } + } + return sb.toString(); } - if (opCode == null) { - throw new WebSocketException(CloseCode.ProtocolError, "Received frame with reserved/unknown opcode " + (head & 0x0F) + "."); - } else if (opCode.isControlFrame() && !fin) { - throw new WebSocketException(CloseCode.ProtocolError, "Fragmented control frame."); + } + + private void readPayload(InputStream in) throws IOException { + this.payload = new byte[this._payloadLength]; + int read = 0; + while (read < this._payloadLength) { + read += checkedRead(in.read(this.payload, read, this._payloadLength - read)); } - WebSocketFrame frame = new WebSocketFrame(opCode, fin); - frame.readPayloadInfo(in); - frame.readPayload(in); - if (frame.getOpCode() == OpCode.Close) { - return new CloseFrame(frame); - } else { - return frame; + if (isMasked()) { + for (int i = 0; i < this.payload.length; i++) { + this.payload[i] ^= this.maskingKey[i % 4]; + } } - } - private static int checkedRead(int read) throws IOException { - if (read < 0) { - throw new EOFException(); + // Test for Unicode errors + if (getOpCode() == OpCode.Text) { + this._payloadString = binary2Text(getBinaryPayload()); } - return read; } + // --------------------------------ENCODING-------------------------------- + private void readPayloadInfo(InputStream in) throws IOException { byte b = (byte) checkedRead(in.read()); - boolean masked = ((b & 0x80) != 0); + boolean masked = (b & 0x80) != 0; - _payloadLength = (byte) (0x7F & b); - if (_payloadLength == 126) { + this._payloadLength = (byte) (0x7F & b); + if (this._payloadLength == 126) { // checkedRead must return int for this to work - _payloadLength = (checkedRead(in.read()) << 8 | checkedRead(in.read())) & 0xFFFF; - if (_payloadLength < 126) { + this._payloadLength = (checkedRead(in.read()) << 8 | checkedRead(in.read())) & 0xFFFF; + if (this._payloadLength < 126) { throw new WebSocketException(CloseCode.ProtocolError, "Invalid data frame 2byte length. (not using minimal length encoding)"); } - } else if (_payloadLength == 127) { + } else if (this._payloadLength == 127) { long _payloadLength = - ((long) checkedRead(in.read())) << 56 | ((long) checkedRead(in.read())) << 48 | ((long) checkedRead(in.read())) << 40 - | ((long) checkedRead(in.read())) << 32 | checkedRead(in.read()) << 24 | checkedRead(in.read()) << 16 | checkedRead(in.read()) << 8 - | checkedRead(in.read()); + (long) checkedRead(in.read()) << 56 | (long) checkedRead(in.read()) << 48 | (long) checkedRead(in.read()) << 40 | (long) checkedRead(in.read()) << 32 + | checkedRead(in.read()) << 24 | checkedRead(in.read()) << 16 | checkedRead(in.read()) << 8 | checkedRead(in.read()); if (_payloadLength < 65536) { throw new WebSocketException(CloseCode.ProtocolError, "Invalid data frame 4byte length. (not using minimal length encoding)"); } @@ -560,96 +600,55 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { this._payloadLength = (int) _payloadLength; } - if (opCode.isControlFrame()) { - if (_payloadLength > 125) { + if (this.opCode.isControlFrame()) { + if (this._payloadLength > 125) { throw new WebSocketException(CloseCode.ProtocolError, "Control frame with payload length > 125 bytes."); } - if (opCode == OpCode.Close && _payloadLength == 1) { + if (this.opCode == OpCode.Close && this._payloadLength == 1) { throw new WebSocketException(CloseCode.ProtocolError, "Received close frame with payload len 1."); } } if (masked) { - maskingKey = new byte[4]; + this.maskingKey = new byte[4]; int read = 0; - while (read < maskingKey.length) { - read += checkedRead(in.read(maskingKey, read, maskingKey.length - read)); + while (read < this.maskingKey.length) { + read += checkedRead(in.read(this.maskingKey, read, this.maskingKey.length - read)); } } } - private void readPayload(InputStream in) throws IOException { - payload = new byte[_payloadLength]; - int read = 0; - while (read < _payloadLength) { - read += checkedRead(in.read(payload, read, _payloadLength - read)); - } - - if (isMasked()) { - for (int i = 0; i < payload.length; i++) { - payload[i] ^= maskingKey[i % 4]; - } - } - - // Test for Unicode errors - if (getOpCode() == OpCode.Text) { - _payloadString = binary2Text(getBinaryPayload()); - } + public void setBinaryPayload(byte[] payload) { + this.payload = payload; + this._payloadLength = payload.length; + this._payloadString = null; } - public void write(OutputStream out) throws IOException { - byte header = 0; - if (fin) { - header |= 0x80; - } - header |= opCode.getValue() & 0x0F; - out.write(header); - - _payloadLength = getBinaryPayload().length; - if (_payloadLength <= 125) { - out.write(isMasked() ? 0x80 | (byte) _payloadLength : (byte) _payloadLength); - } else if (_payloadLength <= 0xFFFF) { - out.write(isMasked() ? 0xFE : 126); - out.write(_payloadLength >>> 8); - out.write(_payloadLength); - } else { - out.write(isMasked() ? 0xFF : 127); - out.write(_payloadLength >>> 56 & 0); // integer only contains - // 31 bit - out.write(_payloadLength >>> 48 & 0); - out.write(_payloadLength >>> 40 & 0); - out.write(_payloadLength >>> 32 & 0); - out.write(_payloadLength >>> 24); - out.write(_payloadLength >>> 16); - out.write(_payloadLength >>> 8); - out.write(_payloadLength); - } + public void setFin(boolean fin) { + this.fin = fin; + } - if (isMasked()) { - out.write(maskingKey); - for (int i = 0; i < _payloadLength; i++) { - out.write(getBinaryPayload()[i] ^ maskingKey[i % 4]); - } - } else { - out.write(getBinaryPayload()); + public void setMaskingKey(byte[] maskingKey) { + if (maskingKey != null && maskingKey.length != 4) { + throw new IllegalArgumentException("MaskingKey " + Arrays.toString(maskingKey) + " hasn't length 4"); } - out.flush(); + this.maskingKey = maskingKey; } - // --------------------------------ENCODING-------------------------------- - - public static final Charset TEXT_CHARSET = Charset.forName("UTF-8"); - - public static String binary2Text(byte[] payload) throws CharacterCodingException { - return new String(payload, TEXT_CHARSET); + public void setOpCode(OpCode opcode) { + this.opCode = opcode; } - public static String binary2Text(byte[] payload, int offset, int length) throws CharacterCodingException { - return new String(payload, offset, length, TEXT_CHARSET); + public void setTextPayload(String payload) throws CharacterCodingException { + this.payload = text2Binary(payload); + this._payloadLength = payload.length(); + this._payloadString = payload; } - public static byte[] text2Binary(String payload) throws CharacterCodingException { - return payload.getBytes(TEXT_CHARSET); + // --------------------------------CONSTANTS------------------------------- + + public void setUnmasked() { + setMaskingKey(null); } @Override @@ -663,187 +662,192 @@ public abstract class NanoWebSocketServer extends NanoHTTPD { return sb.toString(); } - private String payloadToString() { - if (payload == null) - return "null"; - else { - final StringBuilder sb = new StringBuilder(); - sb.append('[').append(payload.length).append("b] "); - if (getOpCode() == OpCode.Text) { - String text = getTextPayload(); - if (text.length() > 100) - sb.append(text.substring(0, 100)).append("..."); - else - sb.append(text); - } else { - sb.append("0x"); - for (int i = 0; i < Math.min(payload.length, 50); ++i) - sb.append(Integer.toHexString((int) payload[i] & 0xFF)); - if (payload.length > 50) - sb.append("..."); - } - return sb.toString(); - } - } - - // --------------------------------CONSTANTS------------------------------- - - public static enum OpCode { - Continuation(0), - Text(1), - Binary(2), - Close(8), - Ping(9), - Pong(10); - - private final byte code; - - private OpCode(int code) { - this.code = (byte) code; - } + // ------------------------------------------------------------------------ - public byte getValue() { - return code; + public void write(OutputStream out) throws IOException { + byte header = 0; + if (this.fin) { + header |= 0x80; } + header |= this.opCode.getValue() & 0x0F; + out.write(header); - public boolean isControlFrame() { - return this == Close || this == Ping || this == Pong; + this._payloadLength = getBinaryPayload().length; + if (this._payloadLength <= 125) { + out.write(isMasked() ? 0x80 | (byte) this._payloadLength : (byte) this._payloadLength); + } else if (this._payloadLength <= 0xFFFF) { + out.write(isMasked() ? 0xFE : 126); + out.write(this._payloadLength >>> 8); + out.write(this._payloadLength); + } else { + out.write(isMasked() ? 0xFF : 127); + out.write(this._payloadLength >>> 56 & 0); // integer only + // contains + // 31 bit + out.write(this._payloadLength >>> 48 & 0); + out.write(this._payloadLength >>> 40 & 0); + out.write(this._payloadLength >>> 32 & 0); + out.write(this._payloadLength >>> 24); + out.write(this._payloadLength >>> 16); + out.write(this._payloadLength >>> 8); + out.write(this._payloadLength); } - public static OpCode find(byte value) { - for (OpCode opcode : values()) { - if (opcode.getValue() == value) { - return opcode; - } + if (isMasked()) { + out.write(this.maskingKey); + for (int i = 0; i < this._payloadLength; i++) { + out.write(getBinaryPayload()[i] ^ this.maskingKey[i % 4]); } - return null; + } else { + out.write(getBinaryPayload()); } + out.flush(); } + } - public static enum CloseCode { - NormalClosure(1000), - GoingAway(1001), - ProtocolError(1002), - UnsupportedData(1003), - NoStatusRcvd(1005), - AbnormalClosure(1006), - InvalidFramePayloadData(1007), - PolicyViolation(1008), - MessageTooBig(1009), - MandatoryExt(1010), - InternalServerError(1011), - TLSHandshake(1015); + /** + * logger to log to. + */ + private static Logger LOG = Logger.getLogger(NanoWebSocketServer.class.getName()); - private final int code; + public static final String HEADER_UPGRADE = "upgrade"; - private CloseCode(int code) { - this.code = code; - } + public static final String HEADER_UPGRADE_VALUE = "websocket"; - public int getValue() { - return code; - } + public static final String HEADER_CONNECTION = "connection"; - public static CloseCode find(int value) { - for (CloseCode code : values()) { - if (code.getValue() == value) { - return code; - } - } - return null; - } - } + public static final String HEADER_CONNECTION_VALUE = "Upgrade"; - // ------------------------------------------------------------------------ + public static final String HEADER_WEBSOCKET_VERSION = "sec-websocket-version"; - public static class CloseFrame extends WebSocketFrame { + public static final String HEADER_WEBSOCKET_VERSION_VALUE = "13"; - private CloseCode _closeCode; + public static final String HEADER_WEBSOCKET_KEY = "sec-websocket-key"; - private String _closeReason; + public static final String HEADER_WEBSOCKET_ACCEPT = "sec-websocket-accept"; - private CloseFrame(WebSocketFrame wrap) throws CharacterCodingException { - super(wrap); - assert wrap.getOpCode() == OpCode.Close; - if (wrap.getBinaryPayload().length >= 2) { - _closeCode = CloseCode.find((wrap.getBinaryPayload()[0] & 0xFF) << 8 | (wrap.getBinaryPayload()[1] & 0xFF)); - _closeReason = binary2Text(getBinaryPayload(), 2, getBinaryPayload().length - 2); - } - } + public static final String HEADER_WEBSOCKET_PROTOCOL = "sec-websocket-protocol"; - public CloseFrame(CloseCode code, String closeReason) throws CharacterCodingException { - super(OpCode.Close, true, generatePayload(code, closeReason)); - } + private final static String WEBSOCKET_KEY_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - private static byte[] generatePayload(CloseCode code, String closeReason) throws CharacterCodingException { - if (code != null) { - byte[] reasonBytes = text2Binary(closeReason); - byte[] payload = new byte[reasonBytes.length + 2]; - payload[0] = (byte) ((code.getValue() >> 8) & 0xFF); - payload[1] = (byte) ((code.getValue()) & 0xFF); - System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length); - return payload; - } else { - return new byte[0]; - } - } + private final static char[] ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray(); - public CloseCode getCloseCode() { - return _closeCode; - } + /** + * Translates the specified byte array into Base64 string. + * <p> + * Android has android.util.Base64, sun has sun.misc.Base64Encoder, Java 8 + * hast java.util.Base64, I have this from stackoverflow: + * http://stackoverflow.com/a/4265472 + * </p> + * + * @param buf + * the byte array (not null) + * @return the translated Base64 string (not null) + */ + private static String encodeBase64(byte[] buf) { + int size = buf.length; + char[] ar = new char[(size + 2) / 3 * 4]; + int a = 0; + int i = 0; + while (i < size) { + byte b0 = buf[i++]; + byte b1 = i < size ? buf[i++] : 0; + byte b2 = i < size ? buf[i++] : 0; - public String getCloseReason() { - return _closeReason; - } + int mask = 0x3F; + ar[a++] = NanoWebSocketServer.ALPHABET[b0 >> 2 & mask]; + ar[a++] = NanoWebSocketServer.ALPHABET[(b0 << 4 | (b1 & 0xFF) >> 4) & mask]; + ar[a++] = NanoWebSocketServer.ALPHABET[(b1 << 2 | (b2 & 0xFF) >> 6) & mask]; + ar[a++] = NanoWebSocketServer.ALPHABET[b2 & mask]; + } + switch (size % 3) { + case 1: + ar[--a] = '='; + case 2: + ar[--a] = '='; } + return new String(ar); } - public static class WebSocketException extends IOException { + public static String makeAcceptKey(String key) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + String text = key + NanoWebSocketServer.WEBSOCKET_KEY_MAGIC; + md.update(text.getBytes(), 0, text.length()); + byte[] sha1hash = md.digest(); + return encodeBase64(sha1hash); + } - private static final long serialVersionUID = 1L; + public NanoWebSocketServer(int port) { + super(port); + } - private CloseCode code; + public NanoWebSocketServer(String hostname, int port) { + super(hostname, port); + } - private String reason; + private boolean isWebSocketConnectionHeader(Map<String, String> headers) { + String connection = headers.get(NanoWebSocketServer.HEADER_CONNECTION); + return connection != null && connection.toLowerCase().contains(NanoWebSocketServer.HEADER_CONNECTION_VALUE.toLowerCase()); + } - public WebSocketException(Exception cause) { - this(CloseCode.InternalServerError, cause.toString(), cause); - } + protected boolean isWebsocketRequested(IHTTPSession session) { + Map<String, String> headers = session.getHeaders(); + String upgrade = headers.get(NanoWebSocketServer.HEADER_UPGRADE); + boolean isCorrectConnection = isWebSocketConnectionHeader(headers); + boolean isUpgrade = NanoWebSocketServer.HEADER_UPGRADE_VALUE.equalsIgnoreCase(upgrade); + return isUpgrade && isCorrectConnection; + } - public WebSocketException(CloseCode code, String reason) { - this(code, reason, null); - } + protected abstract void onClose(WebSocket webSocket, CloseCode code, String reason, boolean initiatedByRemote); - public WebSocketException(CloseCode code, String reason, Exception cause) { - super(code + ": " + reason, cause); - this.code = code; - this.reason = reason; - } + protected abstract void onException(WebSocket webSocket, IOException e); - public CloseCode getCode() { - return code; - } + // --------------------------------Listener-------------------------------- - public String getReason() { - return reason; - } + protected void onFrameReceived(WebSocketFrame webSocket) { + // only for debugging } - // --------------------------------Listener-------------------------------- + protected abstract void onMessage(WebSocket webSocket, WebSocketFrame messageFrame); protected abstract void onPong(WebSocket webSocket, WebSocketFrame pongFrame); - protected abstract void onMessage(WebSocket webSocket, WebSocketFrame messageFrame); + public void onSendFrame(WebSocketFrame webSocket) { + // only for debugging + } - protected abstract void onClose(WebSocket webSocket, CloseCode code, String reason, boolean initiatedByRemote); + public WebSocket openWebSocket(IHTTPSession handshake) { + return new WebSocket(handshake); + } - protected abstract void onException(WebSocket webSocket, IOException e); + @Override + public Response serve(final IHTTPSession session) { + Map<String, String> headers = session.getHeaders(); + if (isWebsocketRequested(session)) { + if (!NanoWebSocketServer.HEADER_WEBSOCKET_VERSION_VALUE.equalsIgnoreCase(headers.get(NanoWebSocketServer.HEADER_WEBSOCKET_VERSION))) { + return new Response(Response.Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Invalid Websocket-Version " + + headers.get(NanoWebSocketServer.HEADER_WEBSOCKET_VERSION)); + } - protected void onFrameReceived(WebSocketFrame webSocket) { - // only for debugging - } + if (!headers.containsKey(NanoWebSocketServer.HEADER_WEBSOCKET_KEY)) { + return new Response(Response.Status.BAD_REQUEST, NanoHTTPD.MIME_PLAINTEXT, "Missing Websocket-Key"); + } - public void onSendFrame(WebSocketFrame webSocket) { - // only for debugging + WebSocket webSocket = openWebSocket(session); + Response handshakeResponse = webSocket.getHandshakeResponse(); + try { + handshakeResponse.addHeader(NanoWebSocketServer.HEADER_WEBSOCKET_ACCEPT, makeAcceptKey(headers.get(NanoWebSocketServer.HEADER_WEBSOCKET_KEY))); + } catch (NoSuchAlgorithmException e) { + return new Response(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "The SHA-1 Algorithm required for websockets is not available on the server."); + } + + if (headers.containsKey(NanoWebSocketServer.HEADER_WEBSOCKET_PROTOCOL)) { + handshakeResponse.addHeader(NanoWebSocketServer.HEADER_WEBSOCKET_PROTOCOL, headers.get(NanoWebSocketServer.HEADER_WEBSOCKET_PROTOCOL).split(",")[0]); + } + + return handshakeResponse; + } else { + return super.serve(session); + } } } diff --git a/websocket/src/main/java/fi/iki/elonen/samples/echo/DebugWebSocketServer.java b/websocket/src/main/java/fi/iki/elonen/samples/echo/DebugWebSocketServer.java index 68c1a7e..b477bb8 100644 --- a/websocket/src/main/java/fi/iki/elonen/samples/echo/DebugWebSocketServer.java +++ b/websocket/src/main/java/fi/iki/elonen/samples/echo/DebugWebSocketServer.java @@ -57,45 +57,45 @@ public class DebugWebSocketServer extends NanoWebSocketServer { } @Override - protected void onPong(WebSocket socket, WebSocketFrame pongFrame) { - if (debug) { - System.out.println("P " + pongFrame); + protected void onClose(WebSocket socket, WebSocketFrame.CloseCode code, String reason, boolean initiatedByRemote) { + if (this.debug) { + System.out.println("C [" + (initiatedByRemote ? "Remote" : "Self") + "] " + (code != null ? code : "UnknownCloseCode[" + code + "]") + + (reason != null && !reason.isEmpty() ? ": " + reason : "")); } } @Override - protected void onMessage(WebSocket socket, WebSocketFrame messageFrame) { - try { - messageFrame.setUnmasked(); - socket.sendFrame(messageFrame); - } catch (IOException e) { - throw new RuntimeException(e); - } + protected void onException(WebSocket socket, IOException e) { + DebugWebSocketServer.LOG.log(Level.SEVERE, "exception occured", e); } @Override - protected void onClose(WebSocket socket, WebSocketFrame.CloseCode code, String reason, boolean initiatedByRemote) { - if (debug) { - System.out.println("C [" + (initiatedByRemote ? "Remote" : "Self") + "] " + (code != null ? code : "UnknownCloseCode[" + code + "]") - + (reason != null && !reason.isEmpty() ? ": " + reason : "")); + protected void onFrameReceived(WebSocketFrame frame) { + if (this.debug) { + System.out.println("R " + frame); } } @Override - protected void onException(WebSocket socket, IOException e) { - LOG.log(Level.SEVERE, "exception occured", e); + protected void onMessage(WebSocket socket, WebSocketFrame messageFrame) { + try { + messageFrame.setUnmasked(); + socket.sendFrame(messageFrame); + } catch (IOException e) { + throw new RuntimeException(e); + } } @Override - protected void onFrameReceived(WebSocketFrame frame) { - if (debug) { - System.out.println("R " + frame); + protected void onPong(WebSocket socket, WebSocketFrame pongFrame) { + if (this.debug) { + System.out.println("P " + pongFrame); } } @Override public void onSendFrame(WebSocketFrame frame) { - if (debug) { + if (this.debug) { System.out.println("S " + frame); } } |