diff options
Diffstat (limited to 'src/main/java/fi/iki/elonen')
-rw-r--r-- | src/main/java/fi/iki/elonen/ChunkedInputStream.java | 340 | ||||
-rw-r--r-- | src/main/java/fi/iki/elonen/NanoHTTPD.java | 1214 |
2 files changed, 1554 insertions, 0 deletions
diff --git a/src/main/java/fi/iki/elonen/ChunkedInputStream.java b/src/main/java/fi/iki/elonen/ChunkedInputStream.java new file mode 100644 index 0000000..1c0ba8d --- /dev/null +++ b/src/main/java/fi/iki/elonen/ChunkedInputStream.java @@ -0,0 +1,340 @@ +package fi.iki.elonen; + +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * <http://www.apache.org/>. + * + */ + +import java.io.IOException; +import java.io.InputStream; + +/** + * Implements chunked transfer coding. The content is received in small chunks. + * Entities transferred using this input stream can be of unlimited length. + * After the stream is read to the end, it provides access to the trailers, + * if any. + * <p> + * Note that this class NEVER closes the underlying stream, even when + * {@link #close()} gets called. Instead, it will read until the "end" of its + * chunking on close, which allows for the seamless execution of subsequent + * HTTP 1.1 requests, while not requiring the client to remember to read the + * entire contents of the response. + * + * + * @since 4.0 + * + */ +public class ChunkedInputStream extends InputStream +{ + + private enum State + { + CHUNK_LEN, CHUNK_DATA, CHUNK_CRLF, CHUNK_INVALID + } + + private static final int BUFFER_SIZE = 2048; + + /** The session input buffer */ + private final InputStream inputStream; + + private State state; + + /** The chunk size */ + private long chunkSize; + + /** The current position within the current chunk */ + private long pos; + + /** True if we've reached the end of stream */ + private boolean eof; + + /** True if this stream is closed */ + private boolean closed; + + /** + * Default constructor. + * + * @param buffer Session input buffer + * @param inputStream Input stream + * @param http1Config Message http1Config. If {@code null} {@link Http1Config#DEFAULT} will be + * used. + * + * @since 4.4 + */ + public ChunkedInputStream( final InputStream inputStream ) + { + super(); + this.inputStream = inputStream; + this.pos = 0L; + this.state = State.CHUNK_LEN; + } + + @Override + public int available() throws IOException + { + final int len = this.inputStream.available(); + return (int)Math.min( len, this.chunkSize - this.pos ); + } + + /** + * <p> + * Returns all the data in a chunked stream in coalesced form. A chunk + * is followed by a CRLF. The method returns -1 as soon as a chunksize of 0 + * is detected. + * </p> + * + * <p> + * Trailer headers are read automatically at the end of the stream and + * can be obtained with the getResponseFooters() method. + * </p> + * + * @return -1 of the end of the stream has been reached or the next data + * byte + * @throws IOException in case of an I/O error + */ + @Override + public int read() throws IOException + { + if ( this.closed ) { + throw new StreamClosedException( "Already closed" ); + } + if ( this.eof ) { + return -1; + } + if ( state != State.CHUNK_DATA ) { + nextChunk(); + if ( this.eof ) { + return -1; + } + } + final int b = inputStream.read(); + if ( b != -1 ) { + pos++; + if ( pos >= chunkSize ) { + state = State.CHUNK_CRLF; + } + return b; + } + throw new MalformedChunkCodingException( "Truncated chunk (expected size: " + chunkSize + "; actual size: " + pos + ")" ); + } + + /** + * Read some bytes from the stream. + * + * @param b The byte array that will hold the contents from the stream. + * @param off The offset into the byte array at which bytes will start to be + * placed. + * @param len the maximum number of bytes that can be returned. + * @return The number of bytes returned or -1 if the end of stream has been + * reached. + * @throws IOException in case of an I/O error + */ + @Override + public int read( final byte[] b, final int off, final int len ) throws IOException + { + if ( closed ) { + throw new StreamClosedException( "Already closed" ); + } + + if ( eof ) { + return -1; + } + if ( state != State.CHUNK_DATA ) { + nextChunk(); + if ( eof ) { + return -1; + } + } + int bytesRead = inputStream.read( b, off, (int)Math.min( len, chunkSize - pos ) ); + if ( bytesRead != -1 ) { + pos += bytesRead; + if ( pos >= chunkSize ) { + state = State.CHUNK_CRLF; + } + return bytesRead; + } + eof = true; + throw new MalformedChunkCodingException( "Truncated chunk (expected size: " + chunkSize + "; actual size: " + pos + ")" ); + } + + /** + * Read some bytes from the stream. + * + * @param b The byte array that will hold the contents from the stream. + * @return The number of bytes returned or -1 if the end of stream has been + * reached. + * @throws IOException in case of an I/O error + */ + @Override + public int read( final byte[] b ) throws IOException + { + return read( b, 0, b.length ); + } + + /** + * Read the next chunk. + * + * @throws IOException in case of an I/O error + */ + private void nextChunk() throws IOException + { + if ( state == State.CHUNK_INVALID ) { + throw new MalformedChunkCodingException( "Corrupt data stream" ); + } + try { + chunkSize = getChunkSize(); + if ( chunkSize < 0L ) { + throw new MalformedChunkCodingException( "Negative chunk size" ); + } + state = State.CHUNK_DATA; + pos = 0L; + if ( chunkSize == 0L ) { + eof = true; + try { + inputStream.read(); + inputStream.read(); + } catch ( IOException e ) { + throw new MalformedChunkCodingException( "No CRLF after final zero chunk" ); + } + } + } catch ( final MalformedChunkCodingException ex ) { + state = State.CHUNK_INVALID; + throw ex; + } + } + + /** + * Expects the stream to start with a chunksize in hex with optional + * comments after a semicolon. The line must end with a CRLF: "a3; some + * comment\r\n" Positions the stream at the start of the next line. + */ + private long getChunkSize() throws IOException + { + int ch; + int prevCh; + boolean hadSemi; + final State st = this.state; + switch ( st ) { + case CHUNK_CRLF: + ch = inputStream.read(); + ch = ( ch << 8 ) | inputStream.read(); + if ( ch < 0 ) { + throw new MalformedChunkCodingException( + "CRLF expected at end of chunk" ); + } + if ( ch != 3338 ) { + throw new MalformedChunkCodingException( + "Unexpected content at the end of chunk" ); + } + state = State.CHUNK_LEN; + //$FALL-THROUGH$ + case CHUNK_LEN: + prevCh = -1; + hadSemi = false; + StringBuilder sb = new StringBuilder( 8 ); + for ( ;; ) { + ch = inputStream.read(); + if ( ch == -1 ) + break; + if ( prevCh == 13 && ch == 10 ) { + break; + } + prevCh = ch; + if ( ch == 13 ) + continue; + if ( ch == ';' ) { + hadSemi = true; + } + if ( hadSemi ) + continue; + sb.append( (char)ch ); + } + if ( prevCh != 13 || ch != 10 ) { + throw new StreamClosedException( "Premature end of chunk coded message body" ); + } + final String s = sb.toString(); + try { + return Long.parseLong( s, 16 ); + } catch ( final NumberFormatException e ) { + throw new MalformedChunkCodingException( "Invalid hex-length in chunk header: " + s ); + } + default: + throw new IllegalStateException( "Inconsistent codec state" ); + } + } + + /** + * Reads the remainder of the chunked message, leaving the underlying + * stream at a position to start reading the next response without + * scanning. But does NOT close the underlying stream. + * + * @throws IOException in case of an I/O error + */ + @Override + public void close() throws IOException + { + if ( !closed ) { + try { + if ( !eof && state != State.CHUNK_INVALID ) { + // Optimistically check if the content has been fully read + // when there's no data remaining in the current chunk. + // This is common when self-terminating content (e.g. JSON) + // is parsed from response streams. + if ( chunkSize == pos && chunkSize > 0 && read() == -1 ) { + return; + } + // read and discard the remainder of the message + final byte[] buff = new byte[ BUFFER_SIZE ]; + while ( read( buff ) >= 0 ) { + } + } + } finally { + eof = true; + closed = true; + } + } + } + + public static class MalformedChunkCodingException extends IOException + { + private static final long serialVersionUID = 7092137465179737109L; + + public MalformedChunkCodingException( String msg ) + { + super( msg ); + } + } + + public static class StreamClosedException extends IOException + { + private static final long serialVersionUID = -5871567871867283867L; + + public StreamClosedException( String msg ) + { + super( msg ); + } + } + +} diff --git a/src/main/java/fi/iki/elonen/NanoHTTPD.java b/src/main/java/fi/iki/elonen/NanoHTTPD.java new file mode 100644 index 0000000..395eca7 --- /dev/null +++ b/src/main/java/fi/iki/elonen/NanoHTTPD.java @@ -0,0 +1,1214 @@ +package fi.iki.elonen; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2015 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PushbackInputStream; +import java.io.UnsupportedEncodingException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.io.input.BoundedInputStream; +import org.apache.commons.io.output.ByteArrayOutputStream; +import org.openslx.util.GrowingThreadPoolExecutor; +import org.openslx.util.PrioThreadFactory; +import org.openslx.util.Util; + +/** + * A simple, tiny, nicely embeddable HTTP server in Java + * <p/> + * <p/> + * NanoHTTPD + * <p> + * Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, + * 2010 by Konstantinos Togias + * </p> + * <p/> + * <p/> + * <b>Features + limitations: </b> + * <ul> + * <p/> + * <li>Only one Java file</li> + * <li>Java 5 compatible</li> + * <li>Released as open source, Modified BSD licence</li> + * <li>No fixed config files, logging, authorization etc. (Implement yourself if + * you need them.)</li> + * <li>Supports parameter parsing of GET and POST methods (+ rudimentary PUT + * support in 1.25)</li> + * <li>Supports both dynamic content and file serving</li> + * <li>Supports file upload (since version 1.2, 2010)</li> + * <li>Supports partial content (streaming)</li> + * <li>Supports ETags</li> + * <li>Never caches anything</li> + * <li>Doesn't limit bandwidth, request time or simultaneous connections</li> + * <li>Default code serves files and shows all HTTP parameters and headers</li> + * <li>File server supports directory listing, index.html and index.htm</li> + * <li>File server supports partial content (streaming)</li> + * <li>File server supports ETags</li> + * <li>File server does the 301 redirection trick for directories without '/'</li> + * <li>File server supports simple skipping for files (continue download)</li> + * <li>File server serves also very long files without memory overhead</li> + * <li>Contains a built-in list of most common MIME types</li> + * <li>All header names are converted to lower case so they don't vary between + * browsers/clients</li> + * <p/> + * </ul> + * <p/> + * <p/> + * <b>How to use: </b> + * <ul> + * <p/> + * <li>Subclass and implement serve() and embed to your own program</li> + * <p/> + * </ul> + * <p/> + * See the separate "LICENSE.md" file for the distribution license (Modified BSD + * licence) + */ +public abstract class NanoHTTPD implements Runnable +{ + + /** + * 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 = 10000; + /** + * 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"; + private final ServerSocket myServerSocket; + private Set<Socket> openConnections = new HashSet<Socket>(); + /** + * Pluggable strategy for asynchronously executing requests. + */ + private final ExecutorService asyncRunner; + + protected int maxRequestSize = 0; + + /** + * Constructs an HTTP server on given port. + */ + public NanoHTTPD( int port ) throws IOException + { + this( null, port ); + } + + /** + * @param hostname Address to listen on + * @param port Port to listen on + */ + public NanoHTTPD( String hostname, int port ) throws IOException + { + this( hostname, port, 24, 16 ); + } + + /** + * @param hostname Address to listen on + * @param port Port to listen on + * @param maxThreads Maximum number of threads to spawn before we start queuing requests + * @param maxQueue Maximum number of requests we queue before we start rejecting them with 503 + */ + public NanoHTTPD( String hostname, int port, int maxThreads, int maxQueue ) throws IOException + { + this( hostname, port, new GrowingThreadPoolExecutor( 2, maxThreads, 1, TimeUnit.MINUTES, + new ArrayBlockingQueue<Runnable>( maxQueue ), new PrioThreadFactory( "httpd" ) ) ); + } + + /** + * Constructs an HTTP server on given hostname and port. + */ + public NanoHTTPD( String hostname, int port, ExecutorService executor ) throws IOException + { + this.asyncRunner = executor; + myServerSocket = new ServerSocket(); + myServerSocket.setReuseAddress( true ); + myServerSocket.bind( ( hostname != null ) ? new InetSocketAddress( hostname, port ) + : new InetSocketAddress( port ) ); + } + + /** + * Start the server. + */ + @Override + public void run() + { + + do { + try { + final Socket sck = myServerSocket.accept(); + registerConnection( sck ); + sck.setSoTimeout( SOCKET_READ_TIMEOUT ); + final InputStream inputStream = sck.getInputStream(); + try { + asyncRunner.execute( new Runnable() { + @Override + public void run() + { + OutputStream outputStream = null; + try { + outputStream = sck.getOutputStream(); + HTTPSession session = new HTTPSession( inputStream, outputStream, + sck.getInetAddress() ); + while ( !sck.isClosed() && !sck.isInputShutdown() && !sck.isOutputShutdown() ) { + 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 ( ! ( e instanceof SocketTimeoutException ) + && ! ( e instanceof SocketException && "NanoHttpd Shutdown".equals( e.getMessage() ) ) ) { + e.printStackTrace(); + } + } finally { + Util.safeClose( outputStream, inputStream, sck ); + unRegisterConnection( sck ); + } + } + } ); + } catch ( RejectedExecutionException e ) { + Util.safeClose( sck, inputStream ); + unRegisterConnection( sck ); + } + } catch ( IOException e ) { + } + } while ( !myServerSocket.isClosed() ); + serverStopped(); + } + + public void serverStopped() + { + + } + + /** + * Stop the server. + */ + public void stop() + { + try { + Util.safeClose( myServerSocket ); + closeAllConnections(); + } catch ( Exception e ) { + e.printStackTrace(); + } + } + + /** + * 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 ) { + Util.safeClose( socket ); + } + } + + public final int getListeningPort() + { + return myServerSocket == null ? -1 : myServerSocket.getLocalPort(); + } + + public final boolean wasStarted() + { + return myServerSocket != null; + } + + public final boolean isAlive() + { + return wasStarted() && !myServerSocket.isClosed(); + } + + /** + * 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 ) + { + Method method = session.getMethod(); + if ( Method.PUT.equals( method ) || Method.POST.equals( method ) ) { + try { + session.parseBody(); + } 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() ); + } + } + + Map<String, String> parms = session.getParms(); + parms.put( QUERY_STRING_PARAMETER, session.getQueryParameterString() ); + return serve( session.getUri(), method, session.getHeaders(), parms, null ); + } + + /** + * 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 ) { + } + 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 ); + } + } + } + return parms; + } + + // ------------------------------------------------------------------------------- // + // + // Threading Strategy. + // + // ------------------------------------------------------------------------------- // + + /** + * 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; + } + } + + // ------------------------------------------------------------------------------- // + + /** + * HTTP response. Return one of these from serve(). + */ + 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 final 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, boolean chunked ) + { + this.status = status; + this.mimeType = mimeType; + this.data = data; + this.chunkedTransfer = chunked; + } + + /** + * Basic constructor. Enable chunked transfer for everything + * except ByteArrayInputStream. + */ + public Response( IStatus status, String mimeType, InputStream data ) + { + this( status, mimeType, data, ! ( data instanceof ByteArrayInputStream ) ); + } + + /** + * Convenience method that makes an InputStream out of given byte array. + */ + public Response( IStatus status, String mimeType, byte[] data ) + { + this( status, mimeType, data == null ? null : new ByteArrayInputStream( data ) ); + } + + /** + * Convenience method that makes an InputStream out of given text. + */ + public Response( IStatus status, String mimeType, String txt ) + { + this( status, mimeType, txt == null ? null : txt.getBytes( StandardCharsets.UTF_8 ) ); + } + + /** + * 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 ); + } + + private static final ThreadLocal<SimpleDateFormat> headerDateFormatter = new ThreadLocal<SimpleDateFormat>() { + @Override + protected SimpleDateFormat initialValue() + { + return new SimpleDateFormat( "E, d MMM yyyy HH:mm:ss 'GMT'" ); + } + }; + + /** + * Sends given response to the socket. + */ + protected void send( OutputStream outputStream, boolean close ) throws IOException + { + String mime = mimeType; + + final StringBuilder sb = new StringBuilder(); + if ( status == null ) { + throw new Error( "sendResponse(): Status can't be null." ); + } + sb.append( "HTTP/1.1 " ); + sb.append( status.getDescription() ); + sb.append( "\r\n" ); + + if ( mime != null ) { + sb.append( "Content-Type: " ); + sb.append( mime ); + sb.append( "\r\n" ); + } + + if ( header.get( "Date" ) == null ) { + sb.append( "Date: " ); + sb.append( headerDateFormatter.get().format( System.currentTimeMillis() ) ); + sb.append( "\r\n" ); + } + + for ( Entry<String, String> item : header.entrySet() ) { + sb.append( item.getKey() ); + sb.append( ": " ); + sb.append( item.getValue() ); + sb.append( "\r\n" ); + } + + sendConnectionHeaderIfNotAlreadyPresent( sb, header, close ); + + if ( requestMethod != Method.HEAD && chunkedTransfer ) { + sendAsChunked( outputStream, sb ); + } else { + int pending = data != null ? data.available() : 0; + pending = sendContentLengthHeaderIfNotAlreadyPresent( sb, header, pending ); + sb.append( "\r\n" ); + outputStream.write( sb.toString().getBytes( StandardCharsets.UTF_8 ) ); + sb.setLength( 0 ); + sendAsFixedLength( outputStream, pending ); + } + + if ( sb.length() != 0 ) { + outputStream.write( sb.toString().getBytes( StandardCharsets.UTF_8 ) ); + } + Util.safeClose( data ); + } + + protected int sendContentLengthHeaderIfNotAlreadyPresent( StringBuilder sb, + 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; + } + } + } + + sb.append( "Content-Length: " ); + sb.append( size ); + sb.append( "\r\n" ); + return size; + } + + protected void sendConnectionHeaderIfNotAlreadyPresent( StringBuilder sb, Map<String, String> header, boolean close ) + { + if ( close ) { + if ( !headerAlreadySent( header, "connection" ) ) { + sb.append( "Connection: close\r\n" ); + } + return; + } + // Client wants keep-alive + if ( !headerAlreadySent( header, "connection" ) ) { + sb.append( "Connection: keep-alive\r\n" ); + } + if ( !headerAlreadySent( header, "keep-alive" ) ) { + sb.append( "Keep-Alive: timeout=" ); + sb.append( SOCKET_READ_TIMEOUT / 1000 - 1 ); + sb.append( "\r\n" ); + } + } + + private boolean headerAlreadySent( Map<String, String> header, String name ) + { + for ( String headerName : header.keySet() ) { + if ( headerName.equalsIgnoreCase( name ) ) + return true; + } + return false; + } + + private static final byte[] CRLF = "\r\n".getBytes(); + private static final byte[] CHUNKED_END = "0\r\n\r\n".getBytes(); + private static final int BUFFER_SIZE = 256 * 1024; + + private void sendAsChunked( OutputStream outputStream, StringBuilder sb ) throws IOException + { + sb.append( "Transfer-Encoding: chunked\r\n" ); + sb.append( "\r\n" ); + outputStream.write( sb.toString().getBytes( StandardCharsets.UTF_8 ) ); + sb.setLength( 0 ); + 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( CHUNKED_END ); + } + + 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; + } + } + } + + 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" ), + SERVICE_UNAVAILABLE( 503, "Service Unavailable" ); + + 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; + } + } + + /** + * 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(); + + /** + * Adds the files in the request body to the files map. + * + * @param files map to modify + */ + void parseBody() throws IOException, ResponseException; + } + + protected class HTTPSession implements IHTTPSession + { + public static final int BUFSIZE = 8192; + private final OutputStream outputStream; + private PushbackInputStream inputStream; + private InputStream wrappedInput; + private int splitbyte; + private int rlen; + private Method method; + private Map<String, String> parms; + private Map<String, String> headers; + private String queryParameterString; + private String remoteIp; + private boolean doClose; + + public HTTPSession( InputStream inputStream, OutputStream outputStream, InetAddress inetAddress ) + { + this.inputStream = new PushbackInputStream( inputStream, BUFSIZE ); + this.outputStream = outputStream; + remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" + : inetAddress.getHostAddress().toString(); + headers = new HashMap<String, String>(); + parms = new HashMap<String, String>(); + } + + @Override + public void execute() throws IOException + { + doClose = true; + 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 ) { + throw e; + } + if ( read == -1 ) { + // socket was been closed + throw new SocketException( "NanoHttpd Shutdown" ); + } + while ( read > 0 ) { + rlen += read; + splitbyte = findHeaderEnd( buf, rlen ); + if ( splitbyte > 0 || rlen >= BUFSIZE ) + break; + // Try to keep reading as long as we've got less than 8kb + read = inputStream.read( buf, rlen, BUFSIZE - rlen ); + if ( maxRequestSize != 0 && rlen > maxRequestSize ) + throw new SocketException( "Request too large" ); + } + if ( splitbyte == 0 ) { + throw new SocketException( "Connection closed" ); + } + } + + if ( splitbyte < rlen ) { + inputStream.unread( buf, splitbyte, rlen - splitbyte ); + } + + parms.clear(); + 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, splitbyte ) ) ); + + // Decode the header into parms and header java properties + decodeHeader( hin, parms, headers ); + + if ( !Util.isEmptyString( headers.get( "trailer" ) ) ) { + throw new ResponseException( Response.Status.BAD_REQUEST, "BAD REQUEST: Trailers not supported." ); + } + + method = Method.lookup( headers.get( "http-method" ) ); + if ( method == null ) { + throw new ResponseException( Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error." ); + } + + doClose = "close".equalsIgnoreCase( headers.get( "connection" ) ) || "1.0".equals( headers.get( "http-version" ) ); + if ( !doClose && method != Method.GET + && !headers.containsKey( "content-length" ) && !"chunked".equals( headers.get( "transfer-encoding" ) ) ) { + doClose = true; + } + + // 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 { + r.setRequestMethod( method ); + r.send( outputStream, doClose ); + } + if ( doClose ) { + Util.safeClose( outputStream, inputStream ); + } else { + InputStream is = getInputStream(); + if ( is != null ) { + while ( is.read( buf ) > 0 ) { + } + } + } + } catch ( SocketException e ) { + // throw it out to close socket object (finalAccept) + throw e; + } catch ( SocketTimeoutException ste ) { + throw ste; + } catch ( IOException ioe ) { + Response r = new Response( Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, + "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage() ); + r.send( outputStream, doClose ); + Util.safeClose( outputStream ); + } catch ( ResponseException re ) { + Response r = new Response( re.getStatus(), MIME_PLAINTEXT, re.getMessage() ); + r.send( outputStream, doClose ); + Util.safeClose( outputStream ); + } + } + + @Override + public void parseBody() throws IOException, ResponseException + { + // If the method is POST, there may be parameters + // in data section, too, read it: + if ( method != Method.POST ) + return; + + long size; + if ( headers.containsKey( "content-length" ) ) { + size = Util.parseInt( headers.get( "content-length" ), -1 ); + } else { + size = -1; + } + + String contentType = null; + String contentEncoding = null; + String contentTypeHeader = headers.get( "content-type" ); + + StringTokenizer st = null; + if ( contentTypeHeader != null ) { + st = new StringTokenizer( contentTypeHeader, "," ); + if ( st.hasMoreTokens() ) { + String part[] = st.nextToken().split( ";\\s*", 2 ); + contentType = part[0].trim(); + if ( part.length == 2 ) { + contentEncoding = part[1]; + } + } + } + Charset cs = StandardCharsets.UTF_8; + if ( contentEncoding != null ) { + try { + cs = Charset.forName( contentEncoding ); + } catch ( Exception e ) { + } + } + + // Use method here so we get the limited/chunked stream if applicable + InputStream is = getInputStream(); + if ( is == null ) + return; + + if ( "multipart/form-data".equalsIgnoreCase( contentType ) ) { + throw new ResponseException( Response.Status.BAD_REQUEST, + "BAD REQUEST: Content type is multipart/form-data, which is not supported" ); + } else if ( "application/x-www-form-urlencoded".equalsIgnoreCase( contentType ) ) { + // Handle application/x-www-form-urlencoded + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte pbuf[] = new byte[ 2000 ]; + while ( size > 0 ) { + int ret = is.read( pbuf, 0, (int)Math.min( size, pbuf.length ) ); + if ( ret <= 0 ) + break; + if ( ret >= 2 && pbuf[ret - 1] == '\n' && pbuf[ret - 2] == '\r' ) + break; + size -= ret; + baos.write( pbuf, 0, ret ); + } + String postLine = new String( baos.toByteArray(), cs ); + baos.close(); + + decodeParms( postLine, parms ); + } // Otherwise leave stream untouched so app can deal with it + } + + /** + * Decodes the sent headers and loads the data into Key/value pairs + */ + private void decodeHeader( BufferedReader in, Map<String, String> parms, + Map<String, String> headers ) + throws ResponseException + { + try { + // Read the request line + String inLine = in.readLine(); + if ( inLine == null ) { + return; + } + + StringTokenizer st = new StringTokenizer( inLine ); + if ( !st.hasMoreTokens() ) { + throw new ResponseException( Response.Status.BAD_REQUEST, + "BAD REQUEST: Syntax error. Usage: GET /example/file.html" ); + } + + String strMethod = st.nextToken(); + + if ( !st.hasMoreTokens() ) { + throw new ResponseException( Response.Status.BAD_REQUEST, + "BAD REQUEST: Missing URI. Usage: GET /example/file.html" ); + } + + String uri = st.nextToken(); + + // Decode parameters from the URI + int qmi = uri.indexOf( '?' ); + if ( qmi >= 0 ) { + decodeParms( uri.substring( qmi + 1 ), parms ); + uri = decodePercent( uri.substring( 0, qmi ) ); + } else { + uri = decodePercent( uri ); + } + + // If there's another token, its protocol version, + // followed by HTTP headers. + // NOTE: this now forces header names lower case since they are + // case insensitive and vary by client. + if ( st.hasMoreTokens() ) { + String strVersion = st.nextToken(); + String line = in.readLine(); + while ( line != null && line.length() > 0 ) { + int p = line.indexOf( ':' ); + if ( p >= 0 ) + headers.put( line.substring( 0, p ).trim().toLowerCase( Locale.US ), + line.substring( p + 1 ).trim() ); + line = in.readLine(); + } + // Add version after, to override headers + int sl = strVersion.indexOf( '/' ); + if ( sl != -1 ) { + headers.put( "http-version", strVersion.substring( sl + 1 ) ); + } + } + + headers.put( "http-uri", uri ); + headers.put( "http-method", strMethod ); + } catch ( IOException ioe ) { + throw new ResponseException( Response.Status.INTERNAL_ERROR, + "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe ); + } + } + + /** + * Find byte index separating header from body. Return value will point + * to first byte after the final \r\n\r\n. + */ + private int findHeaderEnd( final byte[] buf, int rlen ) + { + int splitbyte = 0; + while ( splitbyte + 3 < rlen ) { + if ( buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && buf[splitbyte + 2] == '\r' + && buf[splitbyte + 3] == '\n' ) { + return splitbyte + 4; + } + splitbyte++; + } + return -1; + } + + /** + * 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 ) { + queryParameterString = ""; + return; + } + + 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 final Map<String, String> getParms() + { + return parms; + } + + public String getQueryParameterString() + { + return queryParameterString; + } + + @Override + public final Map<String, String> getHeaders() + { + return headers; + } + + @Override + public final String getUri() + { + return headers.get( "http-uri" ); + } + + @Override + public final Method getMethod() + { + return method; + } + + @SuppressWarnings( "deprecation" ) + @Override + public final InputStream getInputStream() + { + if ( method == Method.GET ) + return null; + if ( wrappedInput == null ) { + String s = headers.get( "content-length" ); + if ( s != null ) { + long cl = Util.parseLong( s, -1 ); + if ( cl == 0 ) + return null; + if ( cl < 0 ) { + doClose = true; + return null; + } + BoundedInputStream bis = new BoundedInputStream( inputStream, cl ); + bis.setPropagateClose( false ); + return wrappedInput = bis; + } + s = headers.get( "transfer-encoding" ); + if ( s != null ) { + if ( "chunked".equalsIgnoreCase( s.trim() ) ) + return wrappedInput = new ChunkedInputStream( inputStream ); + } + } else { + return wrappedInput; + } + if ( !doClose ) + return null; + return inputStream; + } + } + +} |