diff options
author | daniel oeh <daniel.oeh@gmail.com> | 2014-05-18 11:56:15 +0200 |
---|---|---|
committer | daniel oeh <daniel.oeh@gmail.com> | 2014-05-18 11:56:15 +0200 |
commit | 6266f5b2982d3b91a2f07bd227c31ab1e9cd7af8 (patch) | |
tree | 70c255f574eceece25fcbc989afd08846482103a /src | |
parent | 4f8fbf9ed98f646c994d890dc8fd0ffab32e7072 (diff) | |
download | AntennaPod-6266f5b2982d3b91a2f07bd227c31ab1e9cd7af8.zip |
Implemented Httpbin server for HttpDownloader testing
gzip not working yet
Diffstat (limited to 'src')
3 files changed, 1658 insertions, 22 deletions
diff --git a/src/instrumentationTest/de/test/antennapod/service/download/HttpDownloaderTest.java b/src/instrumentationTest/de/test/antennapod/service/download/HttpDownloaderTest.java index 68d07e7ca..ceafb2e8e 100644 --- a/src/instrumentationTest/de/test/antennapod/service/download/HttpDownloaderTest.java +++ b/src/instrumentationTest/de/test/antennapod/service/download/HttpDownloaderTest.java @@ -1,16 +1,17 @@ package instrumentationTest.de.test.antennapod.service.download; -import java.io.File; -import java.io.IOException; - import android.test.InstrumentationTestCase; -import de.danoeh.antennapod.feed.FeedFile; -import de.danoeh.antennapod.service.download.*; - -import android.test.AndroidTestCase; import android.util.Log; +import de.danoeh.antennapod.feed.FeedFile; +import de.danoeh.antennapod.service.download.DownloadRequest; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.service.download.HttpDownloader; import de.danoeh.antennapod.util.DownloadError; -import org.apache.commons.io.FileUtils; +import instrumentationTest.de.test.antennapod.util.service.download.HTTPBin; + +import java.io.File; +import java.io.IOException; public class HttpDownloaderTest extends InstrumentationTestCase { private static final String TAG = "HttpDownloaderTest"; @@ -20,6 +21,8 @@ public class HttpDownloaderTest extends InstrumentationTestCase { private File destDir; + private HTTPBin httpServer; + private static final String BASE_URL = "http://127.0.0.1:" + HTTPBin.PORT; public HttpDownloaderTest() { super(); @@ -32,6 +35,8 @@ public class HttpDownloaderTest extends InstrumentationTestCase { for (File f : contents) { assertTrue(f.delete()); } + + httpServer.stop(); } @Override @@ -40,6 +45,8 @@ public class HttpDownloaderTest extends InstrumentationTestCase { destDir = getInstrumentation().getTargetContext().getExternalFilesDir(DOWNLOAD_DIR); assertNotNull(destDir); assertTrue(destDir.exists()); + httpServer = new HTTPBin(HTTPBin.PORT); + httpServer.start(); } private FeedFileImpl setupFeedFile(String downloadUrl, String title, boolean deleteExisting) { @@ -72,23 +79,15 @@ public class HttpDownloaderTest extends InstrumentationTestCase { } - private static final String URL_404 = "http://httpbin.org/status/404"; - private static final String URL_AUTH = "http://httpbin.org/basic-auth/user/passwd"; + private static final String URL_404 = BASE_URL + "/status/404"; + private static final String URL_AUTH = BASE_URL + "/basic-auth/user/passwd"; public void testPassingHttp() { - download("http://httpbin.org/status/200", "test200", true); - } - - public void testPassingHttps() { - download("https://httpbin.org/status/200", "test200", true); + download(BASE_URL + "/status/200", "test200", true); } public void testRedirect() { - download("http://httpbin.org/redirect/4", "testRedirect", true); - } - - public void testRelativeRedirect() { - download("http://httpbin.org/relative-redirect/4", "testRelativeRedirect", true); + download(BASE_URL + "/redirect/4", "testRedirect", true); } public void testGzip() { @@ -100,7 +99,7 @@ public class HttpDownloaderTest extends InstrumentationTestCase { } public void testCancel() { - final String url = "http://httpbin.org/delay/3"; + final String url = BASE_URL + "/delay/3"; FeedFileImpl feedFile = setupFeedFile(url, "delay", true); final Downloader downloader = new HttpDownloader(new DownloadRequest(feedFile.getFile_url(), url, "delay", 0, feedFile.getTypeAsInt())); Thread t = new Thread() { @@ -137,7 +136,7 @@ public class HttpDownloaderTest extends InstrumentationTestCase { assertTrue(new File(downloader.getDownloadRequest().getDestination()).exists()); } - public void testAuthenticationShouldSucceed() { + public void testAuthenticationShouldSucceed() throws InterruptedException { download(URL_AUTH, "testAuthSuccess", true, true, "user", "passwd", true); } diff --git a/src/instrumentationTest/de/test/antennapod/util/service/download/HTTPBin.java b/src/instrumentationTest/de/test/antennapod/util/service/download/HTTPBin.java new file mode 100644 index 000000000..b65ecf2db --- /dev/null +++ b/src/instrumentationTest/de/test/antennapod/util/service/download/HTTPBin.java @@ -0,0 +1,217 @@ +package instrumentationTest.de.test.antennapod.util.service.download; + +import android.util.Base64; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; + +import java.io.*; +import java.util.Arrays; +import java.util.Map; +import java.util.Random; +import java.util.zip.GZIPOutputStream; + +/** + * Http server for testing purposes + * <p/> + * Supported features: + * <p/> + * /status/code: Returns HTTP response with the given status code + * /redirect/n: Redirects n times + * /delay/n: Delay response for n seconds + * /basic-auth/username/password: Basic auth with username and password + * /gzip/n: Send gzipped data of size n bytes + * /files/id: Accesses the file with the specified ID (this has to be added first via serveFile). + */ +public class HTTPBin extends NanoHTTPD { + private static final String TAG = "HTTPBin"; + public static final int PORT = 8124; + + private static final String MIME_HTML = "text/html"; + private static final String MIME_PLAIN = "text/plain"; + + public HTTPBin(int port) { + super(port); + } + + public HTTPBin() { + super(PORT); + } + + @Override + public Response serve(IHTTPSession session) { + + if (BuildConfig.DEBUG) Log.d(TAG, "Requested url: " + session.getUri()); + + String[] segments = session.getUri().split("/"); + if (segments.length < 3) { + Log.w(TAG, String.format("Invalid number of URI segments: %d %s", segments.length, Arrays.toString(segments))); + get404Error(); + } + + final String func = segments[1]; + final String param = segments[2]; + final Map<String, String> headers = session.getHeaders(); + + if (func.equalsIgnoreCase("status")) { + try { + int code = Integer.parseInt(param); + return getStatus(code); + } catch (NumberFormatException e) { + e.printStackTrace(); + return getInternalError(); + } + + } else if (func.equalsIgnoreCase("redirect")) { + try { + int times = Integer.parseInt(param); + if (times < 0) { + throw new NumberFormatException("times <= 0: " + times); + } + + return getRedirectResponse(times - 1); + } catch (NumberFormatException e) { + e.printStackTrace(); + return getInternalError(); + } + } else if (func.equalsIgnoreCase("delay")) { + try { + int sec = Integer.parseInt(param); + if (sec <= 0) { + throw new NumberFormatException("sec <= 0: " + sec); + } + + Thread.sleep(sec * 1000L); + return getOKResponse(); + } catch (NumberFormatException e) { + e.printStackTrace(); + return getInternalError(); + } catch (InterruptedException e) { + e.printStackTrace(); + return getInternalError(); + } + } else if (func.equalsIgnoreCase("basic-auth")) { + if (!headers.containsKey("authorization")) { + Log.w(TAG, "No credentials provided"); + return getUnauthorizedResponse(); + } + try { + String credentials = new String(Base64.decode(headers.get("authorization").split(" ")[1], 0), "UTF-8"); + String[] credentialParts = credentials.split(":"); + if (credentialParts.length != 2) { + Log.w(TAG, "Unable to split credentials: " + Arrays.toString(credentialParts)); + return getInternalError(); + } + if (credentialParts[0].equals(segments[2]) + && credentialParts[1].equals(segments[3])) { + Log.i(TAG, "Credentials accepted"); + return getOKResponse(); + } else { + Log.w(TAG, String.format("Invalid credentials. Expected %s, %s, but was %s, %s", + segments[2], segments[3], credentialParts[0], credentialParts[1])); + return getUnauthorizedResponse(); + } + + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + return getInternalError(); + } + } else if (func.equalsIgnoreCase("gzip")) { + try { + int size = Integer.parseInt(param); + if (size <= 0) { + Log.w(TAG, "Invalid size for gzipped data: " + size); + throw new NumberFormatException(); + } + + return getGzippedResponse(size); + } catch (NumberFormatException e) { + e.printStackTrace(); + return getInternalError(); + } catch (IOException e) { + e.printStackTrace(); + return getInternalError(); + } + } + + return get404Error(); + } + + private Response getGzippedResponse(int size) throws IOException { + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + final byte[] buffer = new byte[size]; + Random random = new Random(System.currentTimeMillis()); + random.nextBytes(buffer); + + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + GZIPOutputStream gzipOutputStream = new GZIPOutputStream(compressed); + gzipOutputStream.write(buffer); + + InputStream inputStream = new ByteArrayInputStream(compressed.toByteArray()); + Response response = new Response(Response.Status.OK, MIME_PLAIN, inputStream); + response.addHeader("Content-encoding", "gzip"); + response.addHeader("Content-length", String.valueOf(compressed.size())); + return response; + } + + private Response getStatus(final int code) { + Response.IStatus status = (code == 200) ? Response.Status.OK : + (code == 201) ? Response.Status.CREATED : + (code == 206) ? Response.Status.PARTIAL_CONTENT : + (code == 301) ? Response.Status.REDIRECT : + (code == 304) ? Response.Status.NOT_MODIFIED : + (code == 400) ? Response.Status.BAD_REQUEST : + (code == 401) ? Response.Status.UNAUTHORIZED : + (code == 403) ? Response.Status.FORBIDDEN : + (code == 404) ? Response.Status.NOT_FOUND : + (code == 405) ? Response.Status.METHOD_NOT_ALLOWED : + (code == 416) ? Response.Status.RANGE_NOT_SATISFIABLE : + (code == 500) ? Response.Status.INTERNAL_ERROR : new Response.IStatus() { + @Override + public int getRequestStatus() { + return code; + } + + @Override + public String getDescription() { + return "Unknown"; + } + }; + return new Response(status, MIME_HTML, ""); + + } + + private Response getRedirectResponse(int times) { + if (times > 0) { + Response response = new Response(Response.Status.REDIRECT, MIME_HTML, "This resource has been moved permanently"); + response.addHeader("Location", "/redirect/" + times); + return response; + } else if (times == 0) { + return getOKResponse(); + } else { + return getInternalError(); + } + } + + private Response getUnauthorizedResponse() { + Response response = new Response(Response.Status.UNAUTHORIZED, MIME_HTML, ""); + response.addHeader("WWW-Authenticate", "Basic realm=\"Test Realm\""); + return response; + } + + private Response getOKResponse() { + return new Response(Response.Status.OK, MIME_HTML, ""); + } + + private Response getInternalError() { + return new Response(Response.Status.INTERNAL_ERROR, MIME_HTML, "The server encountered an internal error"); + + } + + private Response get404Error() { + return new Response(Response.Status.NOT_FOUND, MIME_HTML, "The requested URL was not found on this server"); + } +} diff --git a/src/instrumentationTest/de/test/antennapod/util/service/download/NanoHTTPD.java b/src/instrumentationTest/de/test/antennapod/util/service/download/NanoHTTPD.java new file mode 100644 index 000000000..916a2af6c --- /dev/null +++ b/src/instrumentationTest/de/test/antennapod/util/service/download/NanoHTTPD.java @@ -0,0 +1,1420 @@ +package instrumentationTest.de.test.antennapod.util.service.download; + +import java.io.*; +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.ByteBuffer; +import java.nio.channels.FileChannel; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.TimeZone; + +/** + * A simple, tiny, nicely embeddable HTTP server in Java + * <p/> + * <p/> + * NanoHTTPD + * <p></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 lowercase 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 { + /** + * 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"; + private final String hostname; + private final int myPort; + private ServerSocket myServerSocket; + private Set<Socket> openConnections = new HashSet<Socket>(); + 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); + } + + /** + * 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()); + } + + private static final void safeClose(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + } + } + } + + private static final void safeClose(Socket closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + } + } + } + + private static final void safeClose(ServerSocket closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + } + } + } + + /** + * Start the server. + * + * @throws IOException if the socket is in use. + */ + public void start() throws IOException { + myServerSocket = new ServerSocket(); + myServerSocket.bind((hostname != null) ? new InetSocketAddress(hostname, myPort) : new InetSocketAddress(myPort)); + + myThread = new Thread(new Runnable() { + @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() { + @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 (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage()))) { + e.printStackTrace(); + } + } finally { + safeClose(outputStream); + safeClose(inputStream); + safeClose(finalAccept); + unRegisterConnection(finalAccept); + } + } + }); + } catch (IOException e) { + } + } 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) { + 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) { + safeClose(socket); + } + } + + 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 delegates to serveFile() and allows directory listing.) + * + * @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 delegates to serveFile() and allows directory listing.) + * + * @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()); + } + } + + 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) { + } + 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. + // + // ------------------------------------------------------------------------------- // + + /** + * 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; + + static Method lookup(String method) { + for (Method m : Method.values()) { + if (m.toString().equalsIgnoreCase(method)) { + return m; + } + } + 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>By default, the server spawns a new Thread for every incoming request. These are set + * to <i>daemon</i> status, and named according to the request number. The name is + * useful when profiling the application.</p> + */ + public static class DefaultAsyncRunner implements AsyncRunner { + private long requestCount; + + @Override + public void exec(Runnable code) { + ++requestCount; + Thread t = new Thread(code); + t.setDaemon(true); + t.setName("NanoHttpd Request Processor (#" + requestCount + ")"); + t.start(); + } + } + + /** + * Default strategy for creating and cleaning up temporary files. + * <p/> + * <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) { + } + } + tempFiles.clear(); + } + } + + /** + * Default strategy for creating and cleaning up temporary files. + * <p/> + * <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 OutputStream fstream; + + public DefaultTempFile(String tempdir) throws IOException { + file = File.createTempFile("NanoHTTPD-", "", new File(tempdir)); + fstream = new FileOutputStream(file); + } + + @Override + public OutputStream open() throws Exception { + return fstream; + } + + @Override + public void delete() throws Exception { + safeClose(fstream); + file.delete(); + } + + @Override + public String getName() { + return file.getAbsolutePath(); + } + } + + /** + * HTTP response. Return one of these from serve(). + */ + public static class Response { + /** + * HTTP status code after processing, e.g. "200 OK", HTTP_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 = HTTP_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) { + uee.printStackTrace(); + } + } + + /** + * 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; + sendContentLengthHeaderIfNotAlreadyPresent(pw, header, pending); + pw.print("\r\n"); + pw.flush(); + sendAsFixedLength(outputStream, pending); + } + outputStream.flush(); + safeClose(data); + } catch (IOException ioe) { + // Couldn't write? No can do. + } + } + + protected void sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, Map<String, String> header, int size) { + if (!headerAlreadySent(header, "content-length")) { + pw.print("Content-Length: "+ size +"\r\n"); + } + } + + protected void sendConnectionHeaderIfNotAlreadyPresent(PrintWriter pw, Map<String, String> header) { + if (!headerAlreadySent(header, "connection")) { + pw.print("Connection: keep-alive\r\n"); + } + } + + private boolean headerAlreadySent(Map<String, String> header, String name) { + boolean alreadySent = false; + for (String headerName : header.keySet()) { + alreadySent |= headerName.equalsIgnoreCase(name); + } + return alreadySent; + } + + 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()); + } + + 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(Status 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 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; + } + } + + /** + * Default strategy for creating and cleaning up temporary files. + */ + private class DefaultTempFileManagerFactory implements TempFileManagerFactory { + @Override + public TempFileManager create() { + return new DefaultTempFileManager(); + } + } + + /** + * 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. + * @arg files - map to modify + */ + void parseBody(Map<String, String> files) throws IOException, ResponseException; + } + + protected class HTTPSession implements IHTTPSession { + public static final int BUFSIZE = 8192; + private final TempFileManager tempFileManager; + private final OutputStream outputStream; + private PushbackInputStream inputStream; + private int splitbyte; + private int rlen; + private String uri; + private Method method; + private Map<String, String> parms; + private Map<String, String> headers; + private CookieHandler cookies; + private String queryParameterString; + + public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) { + this.tempFileManager = tempFileManager; + this.inputStream = new PushbackInputStream(inputStream, BUFSIZE); + this.outputStream = outputStream; + } + + public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) { + this.tempFileManager = tempFileManager; + this.inputStream = new PushbackInputStream(inputStream, BUFSIZE); + this.outputStream = outputStream; + String remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString(); + headers = new HashMap<String, String>(); + + headers.put("remote-addr", remoteIp); + headers.put("http-client-ip", remoteIp); + } + + @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>(); + } + + // 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) { + 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); + } + } + + /** + * Decodes the sent headers and loads the data into Key/value pairs + */ + private void decodeHeader(BufferedReader in, Map<String, String> pre, 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"); + } + + pre.put("method", 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, it's protocol version, + // followed by HTTP headers. Ignore version but parse headers. + // NOTE: this now forces header names lowercase since they are + // case insensitive and vary by client. + if (st.hasMoreTokens()) { + String line = in.readLine(); + while (line != null && line.trim().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(); + } + } + + pre.put("uri", uri); + } catch (IOException ioe) { + throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe); + } + } + + /** + * Decodes the Multipart Body data and put it into Key/Value pairs. + */ + private void decodeMultipartData(String boundary, ByteBuffer fbuf, BufferedReader in, Map<String, String> parms, + Map<String, String> files) throws ResponseException { + try { + int[] bpositions = getBoundaryPositions(fbuf, boundary.getBytes()); + int boundarycount = 1; + String mpline = in.readLine(); + while (mpline != null) { + if (!mpline.contains(boundary)) { + throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but next chunk does not start with boundary. Usage: GET /example/file.html"); + } + boundarycount++; + Map<String, String> item = new HashMap<String, String>(); + mpline = in.readLine(); + while (mpline != null && mpline.trim().length() > 0) { + int p = mpline.indexOf(':'); + if (p != -1) { + item.put(mpline.substring(0, p).trim().toLowerCase(Locale.US), mpline.substring(p + 1).trim()); + } + mpline = in.readLine(); + } + if (mpline != null) { + String contentDisposition = item.get("content-disposition"); + if (contentDisposition == null) { + throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but no content-disposition info found. Usage: GET /example/file.html"); + } + StringTokenizer st = new StringTokenizer(contentDisposition, ";"); + Map<String, String> disposition = new HashMap<String, String>(); + while (st.hasMoreTokens()) { + String token = st.nextToken().trim(); + int p = token.indexOf('='); + if (p != -1) { + disposition.put(token.substring(0, p).trim().toLowerCase(Locale.US), token.substring(p + 1).trim()); + } + } + String pname = disposition.get("name"); + pname = pname.substring(1, pname.length() - 1); + + String value = ""; + if (item.get("content-type") == null) { + while (mpline != null && !mpline.contains(boundary)) { + mpline = in.readLine(); + if (mpline != null) { + int d = mpline.indexOf(boundary); + if (d == -1) { + value += mpline; + } else { + value += mpline.substring(0, d - 2); + } + } + } + } else { + if (boundarycount > bpositions.length) { + throw new ResponseException(Response.Status.INTERNAL_ERROR, "Error processing request"); + } + int offset = stripMultipartHeaders(fbuf, bpositions[boundarycount - 2]); + String path = saveTmpFile(fbuf, offset, bpositions[boundarycount - 1] - offset - 4); + files.put(pname, path); + value = disposition.get("filename"); + value = value.substring(1, value.length() - 1); + do { + mpline = in.readLine(); + } while (mpline != null && !mpline.contains(boundary)); + } + parms.put(pname, value); + } + } + } catch (IOException ioe) { + throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe); + } + } + + /** + * Find byte index separating header from body. It must be the last byte of the first two sequential new lines. + */ + 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 0; + } + + /** + * Find the byte positions where multipart boundaries start. + */ + private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) { + int matchcount = 0; + int matchbyte = -1; + List<Integer> matchbytes = new ArrayList<Integer>(); + for (int i = 0; i < b.limit(); i++) { + if (b.get(i) == boundary[matchcount]) { + if (matchcount == 0) + matchbyte = i; + matchcount++; + if (matchcount == boundary.length) { + matchbytes.add(matchbyte); + matchcount = 0; + matchbyte = -1; + } + } else { + i -= matchcount; + matchcount = 0; + matchbyte = -1; + } + } + int[] ret = new int[matchbytes.size()]; + for (int i = 0; i < ret.length; i++) { + ret[i] = matchbytes.get(i); + } + return ret; + } + + /** + * Retrieves the content of a sent file and saves it to a temporary file. The full path to the saved file is returned. + */ + private String saveTmpFile(ByteBuffer b, int offset, int len) { + String path = ""; + if (len > 0) { + FileOutputStream fileOutputStream = null; + try { + TempFile tempFile = tempFileManager.createTempFile(); + ByteBuffer src = b.duplicate(); + fileOutputStream = new FileOutputStream(tempFile.getName()); + FileChannel dest = fileOutputStream.getChannel(); + src.position(offset).limit(offset + len); + dest.write(src.slice()); + path = tempFile.getName(); + } catch (Exception e) { // Catch exception if any + throw new Error(e); // we won't recover, so throw an error + } finally { + safeClose(fileOutputStream); + } + } + 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. + */ + private int stripMultipartHeaders(ByteBuffer b, int offset) { + int i; + for (i = offset; i < b.limit(); i++) { + if (b.get(i) == '\r' && b.get(++i) == '\n' && b.get(++i) == '\r' && b.get(++i) == '\n') { + break; + } + } + return i + 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 uri; + } + + @Override + public final Method getMethod() { + return method; + } + + @Override + public final InputStream getInputStream() { + return inputStream; + } + + @Override + public CookieHandler getCookies() { + return cookies; + } + } + + public static class Cookie { + private String n, v, e; + + public Cookie(String name, String value, String expires) { + n = name; + v = value; + e = expires; + } + + public Cookie(String name, String value) { + this(name, value, 30); + } + + public Cookie(String name, String value, int numDays) { + n = name; + v = value; + e = getHTTPTime(numDays); + } + + public String getHTTPHeader() { + String fmt = "%s=%s; expires=%s"; + return String.format(fmt, n, v, 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()); + } + } + + /** + * Provides rudimentary support for cookies. + * Doesn't support 'path', 'secure' nor 'httpOnly'. + * Feel free to improve it and/or add unsupported features. + * + * @author LordFokas + */ + public class CookieHandler implements Iterable<String> { + private HashMap<String, String> cookies = new HashMap<String, String>(); + private ArrayList<Cookie> queue = new ArrayList<Cookie>(); + + 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]); + } + } + } + } + + @Override public Iterator<String> iterator() { + return cookies.keySet().iterator(); + } + + /** + * 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 void set(Cookie cookie) { + queue.add(cookie); + } + + /** + * 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); + } + + /** + * 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()); + } + } + } +} |