New web library (http/https)
authorNiki Roo <niki@nikiroo.be>
Mon, 11 May 2020 21:46:45 +0000 (23:46 +0200)
committerNiki Roo <niki@nikiroo.be>
Mon, 11 May 2020 21:46:45 +0000 (23:46 +0200)
48 files changed:
VERSION
libs/licenses/nanohttpd-2.3.1-LICENSE.md [new file with mode: 0644]
libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz [new file with mode: 0644]
src/be/nikiroo/fanfix/Instance.java
src/be/nikiroo/fanfix/Main.java
src/be/nikiroo/fanfix/bundles/Config.java
src/be/nikiroo/fanfix/data/JsonIO.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/MetaResultList.java
src/be/nikiroo/fanfix/library/RemoteLibrary.java
src/be/nikiroo/fanfix/library/RemoteLibraryServer.java
src/be/nikiroo/fanfix/library/WebLibrary.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/WebLibraryServer.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/WebLibraryServerIndex.java [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/actual_size-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/actual_size-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/avicon.ico [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/back-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/back-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/favicon.ico [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_alternative.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_default.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_magic_book.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_pony_book.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/icon_pony_library.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/index.post.html [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/index.pre.html [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/search-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/search-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/style.css [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/unknown-32x32.png [new file with mode: 0644]
src/be/nikiroo/fanfix/library/web/unknown-64x64.png [new file with mode: 0644]
src/be/nikiroo/fanfix/reader/TextOutput.java [new file with mode: 0644]
src/be/nikiroo/utils/CookieUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/HashUtils.java [new file with mode: 0644]
src/be/nikiroo/utils/NanoHTTPD.java [new file with mode: 0644]
src/be/nikiroo/utils/StringUtils.java

diff --git a/VERSION b/VERSION
index ef538c2810938c03ced86f0380977b308a55b37b..47ae1f7823d77b8b3c60cdd0e303f0cb5dd24286 100644 (file)
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.1.2
+3.1.2-dev
diff --git a/libs/licenses/nanohttpd-2.3.1-LICENSE.md b/libs/licenses/nanohttpd-2.3.1-LICENSE.md
new file mode 100644 (file)
index 0000000..8dc4ca7
--- /dev/null
@@ -0,0 +1,12 @@
+Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+* 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.
+
+* Neither the name of the NanoHttpd organization 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.
diff --git a/libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz b/libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz
new file mode 100644 (file)
index 0000000..250f05f
Binary files /dev/null and b/libs/nanohttpd-nanohttpd-project-2.3.1.tar.gz differ
index c3c086fc9cd498ac3c61c84eebcf9e9bc831da1a..a2cb90aa637ea700554ada2b233c5aa2649c3bd7 100644 (file)
@@ -15,6 +15,7 @@ import be.nikiroo.fanfix.library.BasicLibrary;
 import be.nikiroo.fanfix.library.CacheLibrary;
 import be.nikiroo.fanfix.library.LocalLibrary;
 import be.nikiroo.fanfix.library.RemoteLibrary;
+import be.nikiroo.fanfix.library.WebLibrary;
 import be.nikiroo.utils.Cache;
 import be.nikiroo.utils.IOUtils;
 import be.nikiroo.utils.Image;
@@ -67,19 +68,21 @@ public class Instance {
        }
 
        /**
-        * Initialise the instance -- if already initialised, nothing will happen unless
-        * you pass TRUE to <tt>force</tt>.
+        * Initialise the instance -- if already initialised, nothing will happen
+        * unless you pass TRUE to <tt>force</tt>.
         * <p>
-        * Before calling this method, you may call {@link Bundles#setDirectory(String)}
-        * if wanted.
+        * Before calling this method, you may call
+        * {@link Bundles#setDirectory(String)} if wanted.
         * <p>
-        * Note: forcing the initialisation can be dangerous, so make sure to only make
-        * it under controlled circumstances -- for instance, at the start of the
-        * program, you could call {@link Instance#init()}, change some settings because
-        * you want to force those settings (it will also forbid users to change them!)
-        * and then call {@link Instance#init(boolean)} with <tt>force</tt> set to TRUE.
-        * 
-        * @param force force the initialisation even if already initialised
+        * Note: forcing the initialisation can be dangerous, so make sure to only
+        * make it under controlled circumstances -- for instance, at the start of
+        * the program, you could call {@link Instance#init()}, change some settings
+        * because you want to force those settings (it will also forbid users to
+        * change them!) and then call {@link Instance#init(boolean)} with
+        * <tt>force</tt> set to TRUE.
+        * 
+        * @param force
+        *            force the initialisation even if already initialised
         */
        static public void init(boolean force) {
                synchronized (instancelock) {
@@ -95,7 +98,8 @@ public class Instance {
         * <p>
         * Usually for DEBUG/Test purposes.
         * 
-        * @param instance the actual Instance to use
+        * @param instance
+        *            the actual Instance to use
         */
        static public void init(Instance instance) {
                Instance.instance = instance;
@@ -113,8 +117,8 @@ public class Instance {
        /**
         * Actually initialise the instance.
         * <p>
-        * Before calling this method, you may call {@link Bundles#setDirectory(String)}
-        * if wanted.
+        * Before calling this method, you may call
+        * {@link Bundles#setDirectory(String)} if wanted.
         */
        protected Instance() {
                // Before we can configure it:
@@ -156,21 +160,24 @@ public class Instance {
                        int hoursLarge = config.getInteger(Config.CACHE_MAX_TIME_STABLE, 0);
                        cache = new DataLoader(tmp, ua, hours, hoursLarge);
                } catch (IOException e) {
-                       tracer.error(new IOException("Cannot create cache (will continue without cache)", e));
+                       tracer.error(new IOException(
+                                       "Cannot create cache (will continue without cache)", e));
                        cache = new DataLoader(ua);
                }
 
                cache.setTraceHandler(tracer);
 
                // readerTmp / coverDir
-               readerTmp = getFile(UiConfig.CACHE_DIR_LOCAL_READER, configDir, "tmp-reader");
+               readerTmp = getFile(UiConfig.CACHE_DIR_LOCAL_READER, configDir,
+                               "tmp-reader");
                coverDir = getFile(Config.DEFAULT_COVERS_DIR, configDir, "covers");
                coverDir.mkdirs();
 
                try {
                        tempFiles = new TempFiles("fanfix");
                } catch (IOException e) {
-                       tracer.error(new IOException("Cannot create temporary directory", e));
+                       tracer.error(
+                                       new IOException("Cannot create temporary directory", e));
                }
        }
 
@@ -188,7 +195,8 @@ public class Instance {
        /**
         * The traces handler for this {@link Cache}.
         * 
-        * @param tracer the new traces handler or NULL
+        * @param tracer
+        *            the new traces handler or NULL
         */
        public void setTraceHandler(TraceHandler tracer) {
                if (tracer == null) {
@@ -220,7 +228,8 @@ public class Instance {
        /**
         * Reset the configuration.
         * 
-        * @param resetTrans also reset the translation files
+        * @param resetTrans
+        *            also reset the translation files
         */
        public void resetConfig(boolean resetTrans) {
                String dir = Bundles.getDirectory();
@@ -330,7 +339,8 @@ public class Instance {
         * Return the directory where to store temporary files for the remote
         * {@link LocalLibrary}.
         * 
-        * @param host the remote for this host
+        * @param host
+        *            the remote for this host
         * 
         * @return the directory
         */
@@ -342,8 +352,10 @@ public class Instance {
         * Return the directory where to store temporary files for the remote
         * {@link LocalLibrary}.
         * 
-        * @param remoteDir the base remote directory
-        * @param host      the remote for this host
+        * @param remoteDir
+        *            the base remote directory
+        * @param host
+        *            the remote for this host
         * 
         * @return the directory
         */
@@ -364,10 +376,13 @@ public class Instance {
         */
        public boolean isVersionCheckNeeded() {
                try {
-                       long wait = config.getInteger(Config.NETWORK_UPDATE_INTERVAL, 0) * 24 * 60 * 60 * 1000;
+                       long wait = config.getInteger(Config.NETWORK_UPDATE_INTERVAL, 0)
+                                       * 24 * 60 * 60 * 1000;
                        if (wait >= 0) {
-                               String lastUpString = IOUtils.readSmallFile(new File(configDir, "LAST_UPDATE"));
-                               long delay = new Date().getTime() - Long.parseLong(lastUpString);
+                               String lastUpString = IOUtils
+                                               .readSmallFile(new File(configDir, "LAST_UPDATE"));
+                               long delay = new Date().getTime()
+                                               - Long.parseLong(lastUpString);
                                if (delay > wait) {
                                        return true;
                                }
@@ -387,7 +402,8 @@ public class Instance {
         */
        public void setVersionChecked() {
                try {
-                       IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE", Long.toString(new Date().getTime()));
+                       IOUtils.writeSmallFile(new File(configDir), "LAST_UPDATE",
+                                       Long.toString(new Date().getTime()));
                } catch (IOException e) {
                        tracer.error(e);
                }
@@ -405,8 +421,8 @@ public class Instance {
        }
 
        /**
-        * The configuration directory (will check, in order of preference, the system
-        * properties, the environment and then defaults to
+        * The configuration directory (will check, in order of preference, the
+        * system properties, the environment and then defaults to
         * {@link Instance#getHome()}/.fanfix).
         * 
         * @return the config directory
@@ -430,9 +446,11 @@ public class Instance {
         * {@link Instance#uiconfig}, {@link Instance#trans} and
         * {@link Instance#transGui}).
         * 
-        * @param configDir the directory where to find the configuration files
-        * @param refresh   TRUE to reset the configuration files from the default
-        *                  included ones
+        * @param configDir
+        *            the directory where to find the configuration files
+        * @param refresh
+        *            TRUE to reset the configuration files from the default
+        *            included ones
         */
        private void createConfigs(String configDir, boolean refresh) {
                if (!refresh) {
@@ -476,37 +494,56 @@ public class Instance {
        /**
         * Create the default library as specified by the config.
         * 
-        * @param remoteDir the base remote directory if needed
+        * @param remoteDir
+        *            the base remote directory if needed
         * 
         * @return the default {@link BasicLibrary}
         */
        private BasicLibrary createDefaultLibrary(File remoteDir) {
                BasicLibrary lib = null;
 
-               boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED, false);
+               boolean useRemote = config.getBoolean(Config.REMOTE_LIBRARY_ENABLED,
+                               false);
                if (useRemote) {
                        String host = null;
                        int port = -1;
                        try {
-                               host = config.getString(Config.REMOTE_LIBRARY_HOST);
+                               host = config.getString(Config.REMOTE_LIBRARY_HOST,
+                                               "fanfix://localhost");
                                port = config.getInteger(Config.REMOTE_LIBRARY_PORT, -1);
                                String key = config.getString(Config.REMOTE_LIBRARY_KEY);
 
+                               if (!host.startsWith("http://") && !host.startsWith("https://")
+                                               && !host.startsWith("fanfix://")) {
+                                       host = "fanfix://" + host;
+                               }
+
                                tracer.trace("Selecting remote library " + host + ":" + port);
-                               lib = new RemoteLibrary(key, host, port);
-                               lib = new CacheLibrary(getRemoteDir(remoteDir, host), lib, uiconfig);
+
+                               if (host.startsWith("fanfix://")) {
+                                       lib = new RemoteLibrary(key, host, port);
+                               } else {
+                                       lib = new WebLibrary(key, host, port);
+                               }
+
+                               lib = new CacheLibrary(getRemoteDir(remoteDir, host), lib,
+                                               uiconfig);
                        } catch (Exception e) {
-                               tracer.error(new IOException("Cannot create remote library for: " + host + ":" + port, e));
+                               tracer.error(
+                                               new IOException("Cannot create remote library for: "
+                                                               + host + ":" + port, e));
                        }
                } else {
                        String libDir = System.getenv("BOOKS_DIR");
                        if (libDir == null || libDir.isEmpty()) {
-                               libDir = getFile(Config.LIBRARY_DIR, configDir, "$HOME/Books").getPath();
+                               libDir = getFile(Config.LIBRARY_DIR, configDir, "$HOME/Books")
+                                               .getPath();
                        }
                        try {
                                lib = new LocalLibrary(new File(libDir), config);
                        } catch (Exception e) {
-                               tracer.error(new IOException("Cannot create library for directory: " + libDir, e));
+                               tracer.error(new IOException(
+                                               "Cannot create library for directory: " + libDir, e));
                        }
                }
 
@@ -516,9 +553,12 @@ public class Instance {
        /**
         * Return a path, but support the special $HOME variable.
         * 
-        * @param id  the key for the path, which may contain "$HOME"
-        * @param configDir the directory to use as base if not absolute
-        * @param def the default value if none (will be configDir-rooted if needed)
+        * @param id
+        *            the key for the path, which may contain "$HOME"
+        * @param configDir
+        *            the directory to use as base if not absolute
+        * @param def
+        *            the default value if none (will be configDir-rooted if needed)
         * @return the path, with expanded "$HOME" if needed
         */
        protected File getFile(Config id, String configDir, String def) {
@@ -529,9 +569,12 @@ public class Instance {
        /**
         * Return a path, but support the special $HOME variable.
         * 
-        * @param id  the key for the path, which may contain "$HOME"
-        * @param configDir the directory to use as base if not absolute
-        * @param def the default value if none (will be configDir-rooted if needed)
+        * @param id
+        *            the key for the path, which may contain "$HOME"
+        * @param configDir
+        *            the directory to use as base if not absolute
+        * @param def
+        *            the default value if none (will be configDir-rooted if needed)
         * @return the path, with expanded "$HOME" if needed
         */
        protected File getFile(UiConfig id, String configDir, String def) {
@@ -542,8 +585,10 @@ public class Instance {
        /**
         * Return a path, but support the special $HOME variable.
         * 
-        * @param path the path, which may contain "$HOME"
-        * @param configDir the directory to use as base if not absolute
+        * @param path
+        *            the path, which may contain "$HOME"
+        * @param configDir
+        *            the directory to use as base if not absolute
         * @return the path, with expanded "$HOME" if needed
         */
        protected File getFile(String path, String configDir) {
@@ -567,8 +612,8 @@ public class Instance {
         * properties.
         * <p>
         * The environment variable is tested first. Then, the custom property
-        * "fanfix.home" is tried, followed by the usual "user.home" then "java.io.tmp"
-        * if nothing else is found.
+        * "fanfix.home" is tried, followed by the usual "user.home" then
+        * "java.io.tmp" if nothing else is found.
         * 
         * @return the home
         */
@@ -615,7 +660,8 @@ public class Instance {
                String lang = config.getString(Config.LANG);
 
                if (lang == null || lang.isEmpty()) {
-                       if (System.getenv("LANG") != null && !System.getenv("LANG").isEmpty()) {
+                       if (System.getenv("LANG") != null
+                                       && !System.getenv("LANG").isEmpty()) {
                                lang = System.getenv("LANG");
                        }
                }
@@ -630,7 +676,8 @@ public class Instance {
        /**
         * Check that the given environment variable is "enabled".
         * 
-        * @param key the variable to check
+        * @param key
+        *            the variable to check
         * 
         * @return TRUE if it is
         */
@@ -638,7 +685,8 @@ public class Instance {
                String value = System.getenv(key);
                if (value != null) {
                        value = value.trim().toLowerCase();
-                       if ("yes".equals(value) || "true".equals(value) || "on".equals(value) || "1".equals(value)
+                       if ("yes".equals(value) || "true".equals(value)
+                                       || "on".equals(value) || "1".equals(value)
                                        || "y".equals(value)) {
                                return true;
                        }
index c0dd9e0db608b4b8c4394821d63c783926150251..7be305a0977abc17c0fd227f10d1f18c97dca70a 100644 (file)
@@ -19,6 +19,7 @@ import be.nikiroo.fanfix.library.CacheLibrary;
 import be.nikiroo.fanfix.library.LocalLibrary;
 import be.nikiroo.fanfix.library.RemoteLibrary;
 import be.nikiroo.fanfix.library.RemoteLibraryServer;
+import be.nikiroo.fanfix.library.WebLibraryServer;
 import be.nikiroo.fanfix.output.BasicOutput;
 import be.nikiroo.fanfix.output.BasicOutput.OutputType;
 import be.nikiroo.fanfix.reader.BasicReader;
@@ -29,7 +30,6 @@ import be.nikiroo.fanfix.supported.SupportType;
 import be.nikiroo.utils.Progress;
 import be.nikiroo.utils.Version;
 import be.nikiroo.utils.VersionCheck;
-import be.nikiroo.utils.serial.server.ServerObject;
 
 /**
  * Main program entry point.
@@ -79,7 +79,7 @@ public class Main {
         * <li>--version: get the version of the program</li>
         * <li>--server: start the server mode (see config file for parameters)</li>
         * <li>--stop-server: stop the running server on this port if any</li>
-        * <li>--remote [key] [host] [port]: use the given remote library</li>
+        * <li>--remote [key] [host] [port]: use the given remote library</li>
         * </ul>
         * 
         * @param args
@@ -626,15 +626,8 @@ public class Main {
                                }
                                break;
                        case SERVER:
-                               key = Instance.getInstance().getConfig().getString(Config.SERVER_KEY);
-                               port = Instance.getInstance().getConfig().getInteger(Config.SERVER_PORT);
-                               if (port == null) {
-                                       System.err.println("No port configured in the config file");
-                                       exitCode = 15;
-                                       break;
-                               }
                                try {
-                                       startServer(key, port);
+                                       startServer();
                                } catch (IOException e) {
                                        Instance.getInstance().getTraceHandler().error(e);
                                }
@@ -1037,20 +1030,29 @@ public class Main {
        /**
         * Start a Fanfix server.
         * 
-        * @param key
-        *            the key taht will be needed to contact the Fanfix server
-        * @param port
-        *            the port on which to run
-        * 
         * @throws IOException
         *             in case of I/O errors
         * @throws SSLException
         *             when the key was not accepted
         */
-       private void startServer(String key, int port) throws IOException {
-               ServerObject server = new RemoteLibraryServer(key, port);
-               server.setTraceHandler(Instance.getInstance().getTraceHandler());
-               server.run();
+       private void startServer() throws IOException {
+               String mode = Instance.getInstance().getConfig()
+                               .getString(Config.SERVER_MODE, "fanfix");
+               if (mode.equals("fanfix")) {
+                       RemoteLibraryServer server = new RemoteLibraryServer();
+                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
+                       server.run();
+               } else if (mode.equals("http")) {
+                       WebLibraryServer server = new WebLibraryServer(false);
+                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
+                       server.run();
+               } else if (mode.equals("https")) {
+                       WebLibraryServer server = new WebLibraryServer(true);
+                       server.setTraceHandler(Instance.getInstance().getTraceHandler());
+                       server.run();
+               } else {
+                       throw new IOException("Unknown server mode: " + mode);
+               }
        }
 
        /**
index 3af83c1d0ff995ecff9140723fd749f8c51449d4..c96ed22a93e47cf91374e525f38011866f6413fe 100644 (file)
@@ -58,7 +58,7 @@ public enum Config {
        @Meta(description = "Use the remote Fanfix server configured here instead of the local library (if FALSE, the local library will be used instead)",//
        format = Format.BOOLEAN, def = "false")
        REMOTE_LIBRARY_ENABLED, //
-       @Meta(description = "The remote Fanfix server to connect to",//
+       @Meta(description = "The remote Fanfix server to connect to (fanfix://, http://, https:// -- if not specified, fanfix:// is assumed)",//
        format = Format.STRING)
        REMOTE_LIBRARY_HOST, //
        @Meta(description = "The port to use for the remote Fanfix server",//
@@ -84,10 +84,19 @@ public enum Config {
        @Meta(description = "Remote Server configuration\nNote that the key is structured: \"KEY|SUBKEY|wl|rw\"\n- \"KEY\" is the actual encryption key (it can actually be empty, which will still encrypt the messages but of course it will be easier to guess the key)\n- \"SUBKEY\" is the (optional) subkey to use to get additional privileges\n- \"wl\" is a special privilege that allows that subkey to ignore white lists\n- \"rw\" is a special privilege that allows that subkey to modify the library, even if it is not in RW (by default) mode\n\nSome examples:\n- \"super-secret\": a normal key, no special privileges\n- \"you-will-not-guess|azOpd8|wl\": a white-list ignoring key\n- \"new-password|subpass|rw\": a key that allows modifications on the library",//
        group = true)
        SERVER, //
+       @Meta(description = "Remote Server mode: you can use the fanfix protocol (which is encrypted), http (which is not) or https (which requires a keystore.jks file)",//
+       format = Format.FIXED_LIST, list = { "fanfix", "http", "https" }, def = "fanfix")
+       SERVER_MODE,
        @Meta(description = "The port on which we can start the server (must be a valid port, from 1 to 65535)", //
        format = Format.INT, def = "58365")
        SERVER_PORT, //
-       @Meta(description = "The encryption key for the server (NOT including a subkey), it cannot contain the pipe character \"|\" but can be empty (it is *still* encrypted, but with an empty, easy to guess key)",//
+       @Meta(description = "A keystore.jks file, required to use HTTPS (the server will refuse to start in HTTPS mode without this file)", //
+       format = Format.STRING, def = "")
+       SERVER_SSL_KEYSTORE,
+       @Meta(description = "The pass phrase required to open the keystore.jks file (required for HTTPS mode)", //
+       format = Format.PASSWORD, def = "")
+       SERVER_SSL_KEYSTORE_PASS,
+       @Meta(description = "The encryption key for the server (NOT including a subkey), it cannot contain the pipe character \"|\" but can be empty -- is used to encrypt the traffic in fanfix mode (even if empty, traffic will be encrypted in fanfix mode), and used as a password for HTTP (clear text protocol) and HTTPS modes",//
        format = Format.PASSWORD, def = "")
        SERVER_KEY, //
        @Meta(description = "Allow write access to the clients (download story, move story...) without RW subkeys", //
@@ -96,9 +105,12 @@ public enum Config {
        @Meta(description = "If not empty, only the EXACT listed sources will be available for clients without BL subkeys",//
        array = true, format = Format.STRING, def = "")
        SERVER_WHITELIST, //
-       @Meta(description = "The subkeys that the server will allow, including the modes\nA subkey ", //
+       @Meta(description = "The subkeys that the server will allow, including the modes\nA subkey is used as a login for HTTP (clear text protocol) and HTTPS modes", //
        array = true, format = Format.STRING, def = "")
        SERVER_ALLOWED_SUBKEYS, //
+       @Meta(description = "The maximum size of the cache, in MegaBytes, for HTTP and HTTPS servers", //
+       format = Format.INT, def = "100")
+       SERVER_MAX_CACHE_MB,
 
        @Meta(description = "DEBUG options",//
        group = true)
diff --git a/src/be/nikiroo/fanfix/data/JsonIO.java b/src/be/nikiroo/fanfix/data/JsonIO.java
new file mode 100644 (file)
index 0000000..db81db6
--- /dev/null
@@ -0,0 +1,164 @@
+package be.nikiroo.fanfix.data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class JsonIO {
+       static public JSONObject toJson(MetaData meta) {
+               if (meta == null) {
+                       return null;
+               }
+
+               JSONObject json = new JSONObject();
+               put(json, "", MetaData.class.getName());
+               put(json, "luid", meta.getLuid());
+               put(json, "title", meta.getTitle());
+               put(json, "author", meta.getAuthor());
+               put(json, "source", meta.getSource());
+               put(json, "url", meta.getUrl());
+               put(json, "words", meta.getWords());
+               put(json, "creation_date", meta.getCreationDate());
+               put(json, "date", meta.getDate());
+               put(json, "lang", meta.getLang());
+               put(json, "publisher", meta.getPublisher());
+               put(json, "subject", meta.getSubject());
+               put(json, "type", meta.getType());
+               put(json, "uuid", meta.getUuid());
+               put(json, "resume", toJson(meta.getResume()));
+               put(json, "tags", new JSONArray(meta.getTags()));
+
+               return json;
+       }
+
+       /**
+        * 
+        * @param json
+        * 
+        * @return
+        * 
+        * @throws JSONException
+        *             when it cannot be converted
+        */
+       static public MetaData toMetaData(JSONObject json) {
+               if (json == null) {
+                       return null;
+               }
+
+               MetaData meta = new MetaData();
+               meta.setLuid(getString(json, "luid"));
+               meta.setTitle(getString(json, "title"));
+               meta.setAuthor(getString(json, "author"));
+               meta.setSource(getString(json, "source"));
+               meta.setUrl(getString(json, "url"));
+               meta.setWords(getLong(json, "words", 0));
+               meta.setCreationDate(getString(json, "creation_date"));
+               meta.setDate(getString(json, "date"));
+               meta.setLang(getString(json, "lang"));
+               meta.setPublisher(getString(json, "publisher"));
+               meta.setSubject(getString(json, "subject"));
+               meta.setType(getString(json, "type"));
+               meta.setUuid(getString(json, "uuid"));
+
+               meta.setResume(toChapter(getJson(json, "resume")));
+               meta.setTags(toListString(getJsonArr(json, "tags")));
+
+               return meta;
+       }
+
+       static public JSONObject toJson(Chapter chap) {
+               if (chap == null) {
+                       return null;
+               }
+
+               JSONObject json = new JSONObject();
+
+               // TODO
+
+               return json;
+       }
+
+       /**
+        * 
+        * @param json
+        * 
+        * @return
+        * 
+        * @throws JSONException
+        *             when it cannot be converted
+        */
+       static public Chapter toChapter(JSONObject json) {
+               if (json == null) {
+                       return null;
+               }
+
+               Chapter chap = new Chapter(0, "");
+
+               // TODO
+
+               return chap;
+       }
+
+       static public List<String> toListString(JSONArray array) {
+               if (array != null) {
+                       List<String> values = new ArrayList<String>();
+                       for (Object value : array.toList()) {
+                               values.add(value == null ? null : value.toString());
+                       }
+                       return values;
+               }
+
+               return null;
+       }
+
+       static private void put(JSONObject json, String key, Object o) {
+               json.put(key, o == null ? JSONObject.NULL : o);
+       }
+
+       static String getString(JSONObject json, String key) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof String) {
+                               return (String) o;
+                       }
+               }
+
+               return null;
+       }
+
+       static long getLong(JSONObject json, String key, long def) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof Long) {
+                               return (Long) o;
+                       }
+               }
+
+               return def;
+       }
+
+       static JSONObject getJson(JSONObject json, String key) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof JSONObject) {
+                               return (JSONObject) o;
+                       }
+               }
+
+               return null;
+       }
+
+       static JSONArray getJsonArr(JSONObject json, String key) {
+               if (json.has(key)) {
+                       Object o = json.get(key);
+                       if (o instanceof JSONArray) {
+                               return (JSONArray) o;
+                       }
+               }
+
+               return null;
+       }
+}
index 0903740cf9902b77ad8140b04e6a0fdb72c700db..8b8a16721066a0eff6ffe7f5d27d47fd3febaac9 100644 (file)
@@ -224,7 +224,7 @@ public class MetaResultList {
                if (sources == null && authors == null && tags == null) {
                        return metas;
                }
-
+               
                // allow "sources/" hierarchy
                if (sources != null) {
                        List<String> folders = new ArrayList<String>();
index a4f00ceff53546eaf1849e526c656add37fa6d17..9fa8c66190174bd1a92536b995d181a16bc8744a 100644 (file)
@@ -21,6 +21,8 @@ import be.nikiroo.utils.serial.server.ConnectActionClientObject;
  * This {@link BasicLibrary} will access a remote server to list the available
  * stories, and download the ones you try to load to the local directory
  * specified in the configuration.
+ * <p>
+ * This remote library uses a custom fanfix:// protocol.
  * 
  * @author niki
  */
@@ -35,8 +37,8 @@ public class RemoteLibrary extends BasicLibrary {
                }
 
                @Override
-               public Object send(Object data) throws IOException,
-                               NoSuchFieldException, NoSuchMethodException,
+               public Object send(Object data)
+                               throws IOException, NoSuchFieldException, NoSuchMethodException,
                                ClassNotFoundException {
                        Object rep = super.send(data);
                        if (rep instanceof RemoteLibraryException) {
@@ -113,20 +115,26 @@ public class RemoteLibrary extends BasicLibrary {
                        this.subkey = "";
                }
 
+               if (host.startsWith("fanfix://")) {
+                       host = host.substring("fanfix://".length());
+               }
+
                this.host = host;
                this.port = port;
        }
 
        @Override
        public String getLibraryName() {
-               return (rw ? "[READ-ONLY] " : "") + host + ":" + port;
+               return (rw ? "[READ-ONLY] " : "") + "fanfix://" + host + ":" + port;
        }
 
        @Override
        public Status getStatus() {
-               Instance.getInstance().getTraceHandler().trace("Getting remote lib status...");
+               Instance.getInstance().getTraceHandler()
+                               .trace("Getting remote lib status...");
                Status status = getStatusDo();
-               Instance.getInstance().getTraceHandler().trace("Remote lib status: " + status);
+               Instance.getInstance().getTraceHandler()
+                               .trace("Remote lib status: " + status);
                return status;
        }
 
@@ -180,8 +188,8 @@ public class RemoteLibrary extends BasicLibrary {
                        @Override
                        public void action(ConnectActionClientObject action)
                                        throws Exception {
-                               Object rep = action.send(new Object[] { subkey, "GET_COVER",
-                                               luid });
+                               Object rep = action
+                                               .send(new Object[] { subkey, "GET_COVER", luid });
                                result[0] = (Image) rep;
                        }
                });
@@ -232,8 +240,8 @@ public class RemoteLibrary extends BasicLibrary {
                                        pg = new Progress();
                                }
 
-                               Object rep = action.send(new Object[] { subkey, "GET_STORY",
-                                               luid });
+                               Object rep = action
+                                               .send(new Object[] { subkey, "GET_STORY", luid });
 
                                MetaData meta = null;
                                if (rep instanceof MetaData) {
@@ -354,7 +362,7 @@ public class RemoteLibrary extends BasicLibrary {
        // Could work (more slowly) without it
        public MetaData imprt(final URL url, Progress pg) throws IOException {
                // Import the file locally if it is actually a file
-               
+
                if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
                        return super.imprt(url, pg);
                }
@@ -374,8 +382,8 @@ public class RemoteLibrary extends BasicLibrary {
                                        throws Exception {
                                Progress pg = pgF;
 
-                               Object rep = action.send(new Object[] { subkey, "IMPORT",
-                                               url.toString() });
+                               Object rep = action.send(
+                                               new Object[] { subkey, "IMPORT", url.toString() });
 
                                while (true) {
                                        if (!RemoteLibraryServer.updateProgress(pg, rep)) {
@@ -524,8 +532,8 @@ public class RemoteLibrary extends BasicLibrary {
                                        pg = new Progress();
                                }
 
-                               Object rep = action.send(new Object[] { subkey, "GET_METADATA",
-                                               luid });
+                               Object rep = action
+                                               .send(new Object[] { subkey, "GET_METADATA", luid });
 
                                while (true) {
                                        if (!RemoteLibraryServer.updateProgress(pg, rep)) {
index f92c37e8e2ecaf6a1b7604d6b0c914a56b03131a..c150a01b6f8a14f68279fc4bb7358f3374a85e10 100644 (file)
@@ -70,16 +70,16 @@ public class RemoteLibraryServer extends ServerObject {
         * Note: the key we use here is the encryption key (it must not contain a
         * subkey).
         * 
-        * @param key
-        *            the key that will restrict access to this server
-        * @param port
-        *            the port to listen on
-        * 
         * @throws IOException
         *             in case of I/O error
         */
-       public RemoteLibraryServer(String key, int port) throws IOException {
-               super("Fanfix remote library", port, key);
+       public RemoteLibraryServer() throws IOException {
+               super("Fanfix remote library",
+                               Instance.getInstance().getConfig()
+                                               .getInteger(Config.SERVER_PORT),
+                               Instance.getInstance().getConfig()
+                                               .getString(Config.SERVER_KEY));
+               
                setTraceHandler(Instance.getInstance().getTraceHandler());
        }
 
diff --git a/src/be/nikiroo/fanfix/library/WebLibrary.java b/src/be/nikiroo/fanfix/library/WebLibrary.java
new file mode 100644 (file)
index 0000000..369eb23
--- /dev/null
@@ -0,0 +1,236 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.JsonIO;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.Progress;
+
+/**
+ * This {@link BasicLibrary} will access a remote server to list the available
+ * stories, and download the ones you try to load to the local directory
+ * specified in the configuration.
+ * <p>
+ * This remote library uses http:// or https://.
+ * 
+ * @author niki
+ */
+public class WebLibrary extends BasicLibrary {
+       private String host;
+       private int port;
+       private final String key;
+       private final String subkey;
+
+       // informative only (server will make the actual checks)
+       private boolean rw;
+
+       /**
+        * Create a {@link RemoteLibrary} linked to the given server.
+        * <p>
+        * Note that the key is structured:
+        * <tt><b><i>xxx</i></b>(|<b><i>yyy</i></b>|<b>wl</b>)(|<b>rw</b>)</tt>
+        * <p>
+        * Note that anything before the first pipe (<tt>|</tt>) character is
+        * considered to be the encryption key, anything after that character is
+        * called the subkey (including the other pipe characters and flags!).
+        * <p>
+        * This is important because the subkey (including the pipe characters and
+        * flags) must be present as-is in the server configuration file to be
+        * allowed.
+        * <ul>
+        * <li><b><i>xxx</i></b>: the encryption key used to communicate with the
+        * server</li>
+        * <li><b><i>yyy</i></b>: the secondary key</li>
+        * <li><b>rw</b>: flag to allow read and write access if it is not the
+        * default on this server</li>
+        * <li><b>wl</b>: flag to allow access to all the stories (bypassing the
+        * whitelist if it exists)</li>
+        * </ul>
+        * <p>
+        * Some examples:
+        * <ul>
+        * <li><b>my_key</b>: normal connection, will take the default server
+        * options</li>
+        * <li><b>my_key|agzyzz|wl</b>: will ask to bypass the white list (if it
+        * exists)</li>
+        * <li><b>my_key|agzyzz|rw</b>: will ask read-write access (if the default
+        * is read-only)</li>
+        * <li><b>my_key|agzyzz|wl|rw</b>: will ask both read-write access and white
+        * list bypass</li>
+        * </ul>
+        * 
+        * @param key
+        *            the key that will allow us to exchange information with the
+        *            server
+        * @param host
+        *            the host to contact or NULL for localhost
+        * @param port
+        *            the port to contact it on
+        */
+       public WebLibrary(String key, String host, int port) {
+               int index = -1;
+               if (key != null) {
+                       index = key.indexOf('|');
+               }
+
+               if (index >= 0) {
+                       this.key = key.substring(0, index);
+                       this.subkey = key.substring(index + 1);
+               } else {
+                       this.key = key;
+                       this.subkey = "";
+               }
+
+               this.rw = subkey.contains("|rw");
+
+               this.host = host;
+               this.port = port;
+
+               // TODO: not supported yet
+               this.rw = false;
+       }
+
+       @Override
+       public Status getStatus() {
+               try {
+                       download("/");
+               } catch (IOException e) {
+                       try {
+                               download("/style.css");
+                               return Status.UNAUTHORIZED;
+                       } catch (IOException ioe) {
+                               return Status.INVALID;
+                       }
+               }
+
+               return rw ? Status.READ_WRITE : Status.READ_ONLY;
+       }
+
+       @Override
+       public String getLibraryName() {
+               return (rw ? "[READ-ONLY] " : "") + host + ":" + port;
+       }
+
+       @Override
+       public Image getCover(String luid) throws IOException {
+               InputStream in = download("/story/" + luid + "/cover");
+               if (in != null) {
+                       return new Image(in);
+               }
+
+               return null;
+       }
+
+       @Override
+       public void setSourceCover(String source, String luid) throws IOException {
+               // TODO Auto-generated method stub
+               throw new IOException("Not implemented yet");
+       }
+
+       @Override
+       public void setAuthorCover(String author, String luid) throws IOException {
+               // TODO Auto-generated method stub
+               throw new IOException("Not implemented yet");
+       }
+
+       @Override
+       protected List<MetaData> getMetas(Progress pg) throws IOException {
+               List<MetaData> metas = new ArrayList<MetaData>();
+               InputStream in = download("/list/luids");
+               JSONArray jsonArr = new JSONArray(IOUtils.readSmallStream(in));
+               for (int i = 0; i < jsonArr.length(); i++) {
+                       JSONObject json = jsonArr.getJSONObject(i);
+                       metas.add(JsonIO.toMetaData(json));
+               }
+
+               return metas;
+       }
+
+       @Override
+       // Could work (more slowly) without it
+       public MetaData imprt(final URL url, Progress pg) throws IOException {
+               if (true)
+                       throw new IOException("Not implemented yet");
+
+               // Import the file locally if it is actually a file
+
+               if (url == null || url.getProtocol().equalsIgnoreCase("file")) {
+                       return super.imprt(url, pg);
+               }
+
+               // Import it remotely if it is an URL
+
+               // TODO
+               return super.imprt(url, pg);
+       }
+
+       @Override
+       // Could work (more slowly) without it
+       protected synchronized void changeSTA(final String luid,
+                       final String newSource, final String newTitle,
+                       final String newAuthor, Progress pg) throws IOException {
+               // TODO
+               super.changeSTA(luid, newSource, newTitle, newAuthor, pg);
+       }
+
+       @Override
+       protected void updateInfo(MetaData meta) {
+               // Will be taken care of directly server side
+       }
+
+       @Override
+       protected void invalidateInfo(String luid) {
+               // Will be taken care of directly server side
+       }
+
+       // The following methods are only used by Save and Delete in BasicLibrary:
+
+       @Override
+       protected int getNextId() {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected void doDelete(String luid) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       @Override
+       protected Story doSave(Story story, Progress pg) throws IOException {
+               throw new java.lang.InternalError("Should not have been called");
+       }
+
+       //
+
+       @Override
+       public File getFile(final String luid, Progress pg) {
+               throw new java.lang.InternalError(
+                               "Operation not supportorted on remote Libraries");
+       }
+
+       // starts with "/"
+       private InputStream download(String path) throws IOException {
+               URL url = new URL(host + ":" + port + path);
+
+               Map<String, String> post = new HashMap<String, String>();
+               post.put("login", subkey);
+               post.put("password", key);
+
+               return Instance.getInstance().getCache().openNoCache(url, null, post,
+                               null, null);
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/WebLibraryServer.java b/src/be/nikiroo/fanfix/library/WebLibraryServer.java
new file mode 100644 (file)
index 0000000..e0096fc
--- /dev/null
@@ -0,0 +1,1092 @@
+package be.nikiroo.fanfix.library;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.KeyStore;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLServerSocketFactory;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.bundles.Config;
+import be.nikiroo.fanfix.bundles.UiConfig;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.JsonIO;
+import be.nikiroo.fanfix.data.MetaData;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.library.web.WebLibraryServerIndex;
+import be.nikiroo.fanfix.reader.TextOutput;
+import be.nikiroo.utils.CookieUtils;
+import be.nikiroo.utils.IOUtils;
+import be.nikiroo.utils.Image;
+import be.nikiroo.utils.NanoHTTPD;
+import be.nikiroo.utils.NanoHTTPD.IHTTPSession;
+import be.nikiroo.utils.NanoHTTPD.Response;
+import be.nikiroo.utils.NanoHTTPD.Response.Status;
+import be.nikiroo.utils.TraceHandler;
+import be.nikiroo.utils.Version;
+
+public class WebLibraryServer implements Runnable {
+       static private String VIEWER_URL_BASE = "/view/story/";
+       static private String VIEWER_URL = VIEWER_URL_BASE + "{luid}/{chap}/{para}";
+       static private String STORY_URL_BASE = "/story/";
+       static private String STORY_URL = STORY_URL_BASE + "{luid}/{chap}/{para}";
+       static private String STORY_URL_COVER = STORY_URL_BASE + "{luid}/cover";
+       static private String LIST_URL = "/list/";
+
+       private class LoginResult {
+               private boolean success;
+               private boolean rw;
+               private boolean wl;
+               private String wookie;
+               private String token;
+               private boolean badLogin;
+               private boolean badToken;
+
+               public LoginResult(String who, String key, String subkey,
+                               boolean success, boolean rw, boolean wl) {
+                       this.success = success;
+                       this.rw = rw;
+                       this.wl = wl;
+                       this.wookie = CookieUtils.generateCookie(who + key, 0);
+
+                       String opts = "";
+                       if (rw)
+                               opts += "|rw";
+                       if (!wl)
+                               opts += "|wl";
+
+                       this.token = wookie + "~"
+                                       + CookieUtils.generateCookie(wookie + subkey + opts, 0)
+                                       + "~" + opts;
+                       this.badLogin = !success;
+               }
+
+               public LoginResult(String token, String who, String key,
+                               List<String> subkeys) {
+
+                       if (token != null) {
+                               String hashes[] = token.split("~");
+                               if (hashes.length >= 2) {
+                                       String wookie = hashes[0];
+                                       String rehashed = hashes[1];
+                                       String opts = hashes.length > 2 ? hashes[2] : "";
+
+                                       if (CookieUtils.validateCookie(who + key, wookie)) {
+                                               if (subkeys == null) {
+                                                       subkeys = new ArrayList<String>();
+                                               }
+                                               subkeys = new ArrayList<String>(subkeys);
+                                               subkeys.add("");
+
+                                               for (String subkey : subkeys) {
+                                                       if (CookieUtils.validateCookie(
+                                                                       wookie + subkey + opts, rehashed)) {
+                                                               this.wookie = wookie;
+                                                               this.token = token;
+                                                               this.success = true;
+
+                                                               this.rw = opts.contains("|rw");
+                                                               this.wl = !opts.contains("|wl");
+                                                       }
+                                               }
+                                       }
+                               }
+
+                               this.badToken = !success;
+                       }
+
+                       // No token -> no bad token
+               }
+
+               public boolean isSuccess() {
+                       return success;
+               }
+
+               public boolean isRw() {
+                       return rw;
+               }
+
+               public boolean isWl() {
+                       return wl;
+               }
+
+               public String getToken() {
+                       return token;
+               }
+
+               public boolean isBadLogin() {
+                       return badLogin;
+               }
+
+               public boolean isBadToken() {
+                       return badToken;
+               }
+       }
+
+       private NanoHTTPD server;
+       private Map<String, Story> storyCache = new HashMap<String, Story>();
+       private LinkedList<String> storyCacheOrder = new LinkedList<String>();
+       private long storyCacheSize = 0;
+       private long maxStoryCacheSize;
+       private TraceHandler tracer = new TraceHandler();
+
+       public WebLibraryServer(boolean secure) throws IOException {
+               Integer port = Instance.getInstance().getConfig()
+                               .getInteger(Config.SERVER_PORT);
+               if (port == null) {
+                       throw new IOException(
+                                       "Cannot start web server: port not specified");
+               }
+
+               int cacheMb = Instance.getInstance().getConfig()
+                               .getInteger(Config.SERVER_MAX_CACHE_MB, 100);
+               maxStoryCacheSize = cacheMb * 1024 * 1024;
+
+               setTraceHandler(Instance.getInstance().getTraceHandler());
+
+               SSLServerSocketFactory ssf = null;
+               if (secure) {
+                       String keystorePath = Instance.getInstance().getConfig()
+                                       .getString(Config.SERVER_SSL_KEYSTORE, "");
+                       String keystorePass = Instance.getInstance().getConfig()
+                                       .getString(Config.SERVER_SSL_KEYSTORE_PASS);
+
+                       if (secure && keystorePath.isEmpty()) {
+                               throw new IOException(
+                                               "Cannot start a secure web server: no keystore.jks file povided");
+                       }
+
+                       if (!keystorePath.isEmpty()) {
+                               File keystoreFile = new File(keystorePath);
+                               try {
+                                       KeyStore keystore = KeyStore
+                                                       .getInstance(KeyStore.getDefaultType());
+                                       InputStream keystoreStream = new FileInputStream(
+                                                       keystoreFile);
+                                       try {
+                                               keystore.load(keystoreStream,
+                                                               keystorePass.toCharArray());
+                                               KeyManagerFactory keyManagerFactory = KeyManagerFactory
+                                                               .getInstance(KeyManagerFactory
+                                                                               .getDefaultAlgorithm());
+                                               keyManagerFactory.init(keystore,
+                                                               keystorePass.toCharArray());
+                                               ssf = NanoHTTPD.makeSSLSocketFactory(keystore,
+                                                               keyManagerFactory);
+                                       } finally {
+                                               keystoreStream.close();
+                                       }
+                               } catch (Exception e) {
+                                       throw new IOException(e.getMessage());
+                               }
+                       }
+               }
+
+               server = new NanoHTTPD(port) {
+                       @Override
+                       public Response serve(final IHTTPSession session) {
+                               super.serve(session);
+
+                               String query = session.getQueryParameterString(); // a=a%20b&dd=2
+                               Method method = session.getMethod(); // GET, POST..
+                               String uri = session.getUri(); // /home.html
+
+                               // need them in real time (not just those sent by the UA)
+                               Map<String, String> cookies = new HashMap<String, String>();
+                               for (String cookie : session.getCookies()) {
+                                       cookies.put(cookie, session.getCookies().read(cookie));
+                               }
+
+                               List<String> whitelist = Instance.getInstance().getConfig()
+                                               .getList(Config.SERVER_WHITELIST);
+                               if (whitelist == null) {
+                                       whitelist = new ArrayList<String>();
+                               }
+
+                               LoginResult login = null;
+                               Map<String, String> params = session.getParms();
+                               String who = session.getRemoteHostName()
+                                               + session.getRemoteIpAddress();
+                               if (params.get("login") != null) {
+                                       login = login(who, params.get("password"),
+                                                       params.get("login"), whitelist);
+                               } else {
+                                       String token = cookies.get("token");
+                                       login = login(who, token, Instance.getInstance().getConfig()
+                                                       .getList(Config.SERVER_ALLOWED_SUBKEYS));
+                               }
+
+                               if (login.isSuccess()) {
+                                       // refresh token
+                                       session.getCookies().set(new Cookie("token",
+                                                       login.getToken(), "30; path=/"));
+
+                                       // set options
+                                       String optionName = params.get("optionName");
+                                       if (optionName != null && !optionName.isEmpty()) {
+                                               String optionValue = params.get("optionValue");
+                                               if (optionValue == null || optionValue.isEmpty()) {
+                                                       session.getCookies().delete(optionName);
+                                                       cookies.remove(optionName);
+                                               } else {
+                                                       session.getCookies().set(new Cookie(optionName,
+                                                                       optionValue, "; path=/"));
+                                                       cookies.put(optionName, optionValue);
+                                               }
+                                       }
+                               }
+
+                               Response rep = null;
+                               if (!login.isSuccess() && (uri.equals("/") //
+                                               || uri.startsWith(STORY_URL_BASE) //
+                                               || uri.startsWith(VIEWER_URL_BASE) //
+                                               || uri.startsWith(LIST_URL))) {
+                                       rep = loginPage(login, uri);
+                               }
+
+                               if (rep == null) {
+                                       try {
+                                               if (uri.equals("/")) {
+                                                       rep = root(session, cookies, whitelist);
+                                               } else if (uri.startsWith(LIST_URL)) {
+                                                       rep = getList(uri, whitelist);
+                                               } else if (uri.startsWith(STORY_URL_BASE)) {
+                                                       rep = getStoryPart(uri, whitelist);
+                                               } else if (uri.startsWith(VIEWER_URL_BASE)) {
+                                                       rep = getViewer(cookies, uri, whitelist);
+                                               } else if (uri.equals("/logout")) {
+                                                       session.getCookies().delete("token");
+                                                       cookies.remove("token");
+                                                       rep = loginPage(login, uri);
+                                               } else {
+                                                       if (uri.startsWith("/"))
+                                                               uri = uri.substring(1);
+                                                       InputStream in = IOUtils.openResource(
+                                                                       WebLibraryServerIndex.class, uri);
+                                                       if (in != null) {
+                                                               String mimeType = MIME_PLAINTEXT;
+                                                               if (uri.endsWith(".css")) {
+                                                                       mimeType = "text/css";
+                                                               } else if (uri.endsWith(".html")) {
+                                                                       mimeType = "text/html";
+                                                               } else if (uri.endsWith(".js")) {
+                                                                       mimeType = "text/javascript";
+                                                               }
+                                                               rep = newChunkedResponse(Status.OK, mimeType,
+                                                                               in);
+                                                       } else {
+                                                               getTraceHandler().trace("404: " + uri);
+                                                       }
+                                               }
+
+                                               if (rep == null) {
+                                                       rep = newFixedLengthResponse(Status.NOT_FOUND,
+                                                                       NanoHTTPD.MIME_PLAINTEXT, "Not Found");
+                                               }
+                                       } catch (Exception e) {
+                                               Instance.getInstance().getTraceHandler().error(
+                                                               new IOException("Cannot process web request",
+                                                                               e));
+                                               rep = newFixedLengthResponse(Status.INTERNAL_ERROR,
+                                                               NanoHTTPD.MIME_PLAINTEXT, "An error occured");
+                                       }
+                               }
+
+                               return rep;
+
+                               // Get status: for story, use "luid" + active map of current
+                               // luids
+                               // map must use a addRef/removeRef and delete at 0
+
+                               // http://localhost:2000/?token=ok
+
+                               //
+                               // MetaData meta = new MetaData();
+                               // meta.setTitle("Title");
+                               // meta.setLuid("000");
+                               //
+                               // JSONObject json = new JSONObject();
+                               // json.put("", MetaData.class.getName());
+                               // json.put("title", meta.getTitle());
+                               // json.put("luid", meta.getLuid());
+                               //
+                               // return newFixedLengthResponse(json.toString());
+                       }
+               };
+
+               if (ssf != null) {
+                       getTraceHandler().trace("Install SSL on the web server...");
+                       server.makeSecure(ssf, null);
+                       getTraceHandler().trace("Done.");
+               }
+       }
+
+       @Override
+       public void run() {
+               try {
+                       server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
+               } catch (IOException e) {
+                       tracer.error(new IOException("Cannot start the web server", e));
+               }
+       }
+
+       /**
+        * Start the server (listen on the network for new connections).
+        * <p>
+        * Can only be called once.
+        * <p>
+        * This call is asynchronous, and will just start a new {@link Thread} on
+        * itself (see {@link WebLibraryServer#run()}).
+        */
+       public void start() {
+               new Thread(this).start();
+       }
+
+       /**
+        * The traces handler for this {@link WebLibraryServer}.
+        * 
+        * @return the traces handler
+        */
+       public TraceHandler getTraceHandler() {
+               return tracer;
+       }
+
+       /**
+        * The traces handler for this {@link WebLibraryServer}.
+        * 
+        * @param tracer
+        *            the new traces handler
+        */
+       public void setTraceHandler(TraceHandler tracer) {
+               if (tracer == null) {
+                       tracer = new TraceHandler(false, false, false);
+               }
+
+               this.tracer = tracer;
+       }
+
+       private LoginResult login(String who, String token, List<String> subkeys) {
+               String realKey = Instance.getInstance().getConfig()
+                               .getString(Config.SERVER_KEY);
+               realKey = realKey == null ? "" : realKey;
+               return new LoginResult(token, who, realKey, subkeys);
+       }
+
+       // allow rw/wl
+       private LoginResult login(String who, String key, String subkey,
+                       List<String> whitelist) {
+               String realKey = Instance.getInstance().getConfig()
+                               .getString(Config.SERVER_KEY);
+
+               // I don't like NULLs...
+               realKey = realKey == null ? "" : realKey;
+               key = key == null ? "" : key;
+               subkey = subkey == null ? "" : subkey;
+
+               if (!realKey.equals(key)) {
+                       return new LoginResult(null, null, null, false, false, false);
+               }
+
+               // defaults are positive (as previous versions without the feature)
+               boolean rw = true;
+               boolean wl = true;
+
+               if (whitelist.isEmpty()) {
+                       wl = false;
+               }
+
+               rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
+                               rw);
+               if (!subkey.isEmpty()) {
+                       List<String> allowed = Instance.getInstance().getConfig()
+                                       .getList(Config.SERVER_ALLOWED_SUBKEYS);
+                       if (allowed != null && allowed.contains(subkey)) {
+                               if ((subkey + "|").contains("|rw|")) {
+                                       rw = true;
+                               }
+                               if ((subkey + "|").contains("|wl|")) {
+                                       wl = false; // |wl| = bypass whitelist
+                               }
+                       } else {
+                               return new LoginResult(null, null, null, false, false, false);
+                       }
+               }
+
+               return new LoginResult(who, key, subkey, true, rw, wl);
+       }
+
+       private Response loginPage(LoginResult login, String uri) {
+               StringBuilder builder = new StringBuilder();
+
+               appendPreHtml(builder, true);
+
+               if (login.isBadLogin()) {
+                       builder.append("<div class='error'>Bad login or password</div>");
+               } else if (login.isBadToken()) {
+                       builder.append("<div class='error'>Your session timed out</div>");
+               }
+
+               if (uri.equals("/logout")) {
+                       uri = "/";
+               }
+
+               builder.append(
+                               "<form method='POST' action='" + uri + "' class='login'>\n");
+               builder.append(
+                               "<p>You must be logged into the system to see the stories.</p>");
+               builder.append("\t<input type='text' name='login' />\n");
+               builder.append("\t<input type='password' name='password' />\n");
+               builder.append("\t<input type='submit' value='Login' />\n");
+               builder.append("</form>\n");
+
+               appendPostHtml(builder);
+
+               return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
+                               NanoHTTPD.MIME_HTML, builder.toString());
+       }
+
+       protected Response getList(String uri, List<String> whitelist)
+                       throws IOException {
+               if (uri.equals("/list/luids")) {
+                       BasicLibrary lib = Instance.getInstance().getLibrary();
+                       List<MetaData> metas = lib.getList().filter(whitelist, null, null);
+                       List<JSONObject> jsons = new ArrayList<JSONObject>();
+                       for (MetaData meta : metas) {
+                               jsons.add(JsonIO.toJson(meta));
+                       }
+
+                       return newInputStreamResponse("application/json",
+                                       new ByteArrayInputStream(
+                                                       new JSONArray(jsons).toString().getBytes()));
+               }
+
+               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                               NanoHTTPD.MIME_PLAINTEXT, null);
+       }
+
+       private Response root(IHTTPSession session, Map<String, String> cookies,
+                       List<String> whitelist) throws IOException {
+               BasicLibrary lib = Instance.getInstance().getLibrary();
+               MetaResultList result = lib.getList();
+               result = new MetaResultList(result.filter(whitelist, null, null));
+               StringBuilder builder = new StringBuilder();
+
+               appendPreHtml(builder, true);
+
+               String filter = cookies.get("filter");
+               if (filter == null) {
+                       filter = "";
+               }
+
+               Map<String, String> params = session.getParms();
+               String browser = params.get("browser") == null ? ""
+                               : params.get("browser");
+               String browser2 = params.get("browser2") == null ? ""
+                               : params.get("browser2");
+               String browser3 = params.get("browser3") == null ? ""
+                               : params.get("browser3");
+
+               String filterSource = null;
+               String filterAuthor = null;
+               String filterTag = null;
+
+               // TODO: javascript in realtime, using visible=false + hide [submit]
+
+               builder.append("<form class='browser'>\n");
+               builder.append("<div class='breadcrumbs'>\n");
+
+               builder.append("\t<select name='browser'>");
+               appendOption(builder, 2, "", "", browser);
+               appendOption(builder, 2, "Sources", "sources", browser);
+               appendOption(builder, 2, "Authors", "authors", browser);
+               appendOption(builder, 2, "Tags", "tags", browser);
+               builder.append("\t</select>\n");
+
+               if (!browser.isEmpty()) {
+                       builder.append("\t<select name='browser2'>");
+                       if (browser.equals("sources")) {
+                               filterSource = browser2.isEmpty() ? filterSource : browser2;
+                               // TODO: if 1 group -> no group
+                               appendOption(builder, 2, "", "", browser2);
+                               Map<String, List<String>> sources = result.getSourcesGrouped();
+                               for (String source : sources.keySet()) {
+                                       appendOption(builder, 2, source, source, browser2);
+                               }
+                       } else if (browser.equals("authors")) {
+                               filterAuthor = browser2.isEmpty() ? filterAuthor : browser2;
+                               // TODO: if 1 group -> no group
+                               appendOption(builder, 2, "", "", browser2);
+                               Map<String, List<String>> authors = result.getAuthorsGrouped();
+                               for (String author : authors.keySet()) {
+                                       appendOption(builder, 2, author, author, browser2);
+                               }
+                       } else if (browser.equals("tags")) {
+                               filterTag = browser2.isEmpty() ? filterTag : browser2;
+                               appendOption(builder, 2, "", "", browser2);
+                               for (String tag : result.getTags()) {
+                                       appendOption(builder, 2, tag, tag, browser2);
+                               }
+                       }
+                       builder.append("\t</select>\n");
+               }
+
+               if (!browser2.isEmpty()) {
+                       if (browser.equals("sources")) {
+                               filterSource = browser3.isEmpty() ? filterSource : browser3;
+                               Map<String, List<String>> sourcesGrouped = result
+                                               .getSourcesGrouped();
+                               List<String> sources = sourcesGrouped.get(browser2);
+                               if (sources != null && !sources.isEmpty()) {
+                                       // TODO: single empty value
+                                       builder.append("\t<select name='browser3'>");
+                                       appendOption(builder, 2, "", "", browser3);
+                                       for (String source : sources) {
+                                               appendOption(builder, 2, source, source, browser3);
+                                       }
+                                       builder.append("\t</select>\n");
+                               }
+                       } else if (browser.equals("authors")) {
+                               filterAuthor = browser3.isEmpty() ? filterAuthor : browser3;
+                               Map<String, List<String>> authorsGrouped = result
+                                               .getAuthorsGrouped();
+                               List<String> authors = authorsGrouped.get(browser2);
+                               if (authors != null && !authors.isEmpty()) {
+                                       // TODO: single empty value
+                                       builder.append("\t<select name='browser3'>");
+                                       appendOption(builder, 2, "", "", browser3);
+                                       for (String author : authors) {
+                                               appendOption(builder, 2, author, author, browser3);
+                                       }
+                                       builder.append("\t</select>\n");
+                               }
+                       }
+               }
+
+               builder.append("\t<input type='submit' value='Select'/>\n");
+               builder.append("</div>\n");
+
+               // TODO: javascript in realtime, using visible=false + hide [submit]
+               builder.append("<div class='filter'>\n");
+               builder.append("\tFilter: \n");
+               builder.append(
+                               "\t<input name='optionName'  type='hidden' value='filter' />\n");
+               builder.append("\t<input name='optionValue' type='text'   value='"
+                               + filter + "' place-holder='...' />\n");
+               builder.append(
+                               "\t<input name='submit' type='submit' value='Filter' />\n");
+               builder.append("</div>\n");
+               builder.append("</form>\n");
+
+               builder.append("\t<div class='books'>");
+               for (MetaData meta : result.getMetas()) {
+                       if (!filter.isEmpty() && !meta.getTitle().toLowerCase()
+                                       .contains(filter.toLowerCase())) {
+                               continue;
+                       }
+
+                       // TODO Sub sources
+                       if (filterSource != null
+                                       && !filterSource.equals(meta.getSource())) {
+                               continue;
+                       }
+
+                       // TODO: sub authors
+                       if (filterAuthor != null
+                                       && !filterAuthor.equals(meta.getAuthor())) {
+                               continue;
+                       }
+
+                       if (filterTag != null && !meta.getTags().contains(filterTag)) {
+                               continue;
+                       }
+
+                       builder.append("<div class='book_line'>");
+                       builder.append("<a href='");
+                       builder.append(getViewUrl(meta.getLuid(), 0, null));
+                       builder.append("'");
+                       builder.append(" class='link'>");
+
+                       if (lib.isCached(meta.getLuid())) {
+                               // â—‰ = &#9673;
+                               builder.append(
+                                               "<span class='cache_icon cached'>&#9673;</span>");
+                       } else {
+                               // â—‹ = &#9675;
+                               builder.append(
+                                               "<span class='cache_icon uncached'>&#9675;</span>");
+                       }
+                       builder.append("<span class='luid'>");
+                       builder.append(meta.getLuid());
+                       builder.append("</span>");
+                       builder.append("<span class='title'>");
+                       builder.append(meta.getTitle());
+                       builder.append("</span>");
+                       builder.append("<span class='author'>");
+                       if (meta.getAuthor() != null && !meta.getAuthor().isEmpty()) {
+                               builder.append("(").append(meta.getAuthor()).append(")");
+                       }
+                       builder.append("</span>");
+                       builder.append("</a></div>\n");
+               }
+               builder.append("</div>");
+
+               appendPostHtml(builder);
+               return NanoHTTPD.newFixedLengthResponse(builder.toString());
+       }
+
+       // /story/luid/chapter/para <-- text/image
+       // /story/luid/cover <-- image
+       // /story/luid/metadata <-- json
+       private Response getStoryPart(String uri, List<String> whitelist) {
+               String[] cover = uri.split("/");
+               int off = 2;
+
+               if (cover.length < off + 2) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                       NanoHTTPD.MIME_PLAINTEXT, null);
+               }
+
+               String luid = cover[off + 0];
+               String chapterStr = cover[off + 1];
+               String imageStr = cover.length < off + 3 ? null : cover[off + 2];
+
+               // 1-based (0 = desc)
+               int chapter = 0;
+               if (chapterStr != null && !"cover".equals(chapterStr)
+                               && !"metadata".equals(chapterStr)) {
+                       try {
+                               chapter = Integer.parseInt(chapterStr);
+                               if (chapter < 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
+                       }
+               }
+
+               // 1-based
+               int paragraph = 1;
+               if (imageStr != null) {
+                       try {
+                               paragraph = Integer.parseInt(imageStr);
+                               if (paragraph < 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
+                       }
+               }
+
+               String mimeType = NanoHTTPD.MIME_PLAINTEXT;
+               InputStream in = null;
+               try {
+                       if ("cover".equals(chapterStr)) {
+                               Image img = getCover(luid, whitelist);
+                               if (img != null) {
+                                       in = img.newInputStream();
+                               }
+                       } else if ("metadata".equals(chapterStr)) {
+                               MetaData meta = meta(luid, whitelist);
+                               JSONObject json = JsonIO.toJson(meta);
+                               mimeType = "application/json";
+                               in = new ByteArrayInputStream(json.toString().getBytes());
+                       } else {
+                               Story story = story(luid, whitelist);
+                               if (story != null) {
+                                       if (chapter == 0) {
+                                               StringBuilder builder = new StringBuilder();
+                                               for (Paragraph p : story.getMeta().getResume()) {
+                                                       if (builder.length() == 0) {
+                                                               builder.append("\n");
+                                                       }
+                                                       builder.append(p.getContent());
+                                               }
+
+                                               in = new ByteArrayInputStream(
+                                                               builder.toString().getBytes("utf-8"));
+                                       } else {
+                                               Paragraph para = story.getChapters().get(chapter - 1)
+                                                               .getParagraphs().get(paragraph - 1);
+                                               Image img = para.getContentImage();
+                                               if (para.getType() == ParagraphType.IMAGE) {
+                                                       // TODO: get correct image type
+                                                       mimeType = "image/png";
+                                                       in = img.newInputStream();
+                                               } else {
+                                                       in = new ByteArrayInputStream(
+                                                                       para.getContent().getBytes("utf-8"));
+                                               }
+                                       }
+                               }
+                       }
+               } catch (IndexOutOfBoundsException e) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+                                       NanoHTTPD.MIME_PLAINTEXT,
+                                       "Chapter or paragraph does not exist");
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler()
+                                       .error(new IOException("Cannot get image: " + uri, e));
+                       return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
+                                       NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
+               }
+
+               return newInputStreamResponse(mimeType, in);
+       }
+
+       private Response getViewer(Map<String, String> cookies, String uri,
+                       List<String> whitelist) {
+               String[] cover = uri.split("/");
+               int off = 2;
+
+               if (cover.length < off + 2) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                       NanoHTTPD.MIME_PLAINTEXT, null);
+               }
+
+               String type = cover[off + 0];
+               String luid = cover[off + 1];
+               String chapterStr = cover.length < off + 3 ? null : cover[off + 2];
+               String paragraphStr = cover.length < off + 4 ? null : cover[off + 3];
+
+               // 1-based (0 = desc)
+               int chapter = -1;
+               if (chapterStr != null) {
+                       try {
+                               chapter = Integer.parseInt(chapterStr);
+                               if (chapter < 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
+                       }
+               }
+
+               // 1-based
+               int paragraph = 0;
+               if (paragraphStr != null) {
+                       try {
+                               paragraph = Integer.parseInt(paragraphStr);
+                               if (paragraph <= 0) {
+                                       throw new NumberFormatException();
+                               }
+                       } catch (NumberFormatException e) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
+                       }
+               }
+
+               try {
+                       Story story = story(luid, whitelist);
+                       if (story == null) {
+                               return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+                                               NanoHTTPD.MIME_PLAINTEXT, "Story not found");
+                       }
+
+                       StringBuilder builder = new StringBuilder();
+                       appendPreHtml(builder, false);
+
+                       if (chapter < 0) {
+                               builder.append(story);
+                       } else {
+                               if (chapter == 0) {
+                                       // TODO: description
+                                       chapter = 1;
+                               }
+
+                               Chapter chap = null;
+                               try {
+                                       chap = story.getChapters().get(chapter - 1);
+                               } catch (IndexOutOfBoundsException e) {
+                                       return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
+                                                       NanoHTTPD.MIME_PLAINTEXT, "Chapter not found");
+                               }
+
+                               if (story.getMeta().isImageDocument() && paragraph <= 0) {
+                                       paragraph = 1;
+                               }
+
+                               String first, previous, next, last;
+                               String content;
+
+                               if (paragraph <= 0) {
+                                       first = getViewUrl(luid, 1, null);
+                                       previous = getViewUrl(luid, (Math.max(chapter - 1, 1)),
+                                                       null);
+                                       next = getViewUrl(luid,
+                                                       (Math.min(chapter + 1, story.getChapters().size())),
+                                                       null);
+                                       last = getViewUrl(luid, story.getChapters().size(), null);
+
+                                       content = "<div class='viewer text'>\n"
+                                                       + new TextOutput(false).convert(chap, true)
+                                                       + "</div>\n";
+                               } else {
+                                       first = getViewUrl(luid, chapter, 1);
+                                       previous = getViewUrl(luid, chapter,
+                                                       (Math.max(paragraph - 1, 1)));
+                                       next = getViewUrl(luid, chapter, (Math.min(paragraph + 1,
+                                                       chap.getParagraphs().size())));
+                                       last = getViewUrl(luid, chapter,
+                                                       chap.getParagraphs().size());
+
+                                       Paragraph para = null;
+                                       try {
+                                               para = chap.getParagraphs().get(paragraph - 1);
+                                       } catch (IndexOutOfBoundsException e) {
+                                               return NanoHTTPD.newFixedLengthResponse(
+                                                               Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
+                                                               "Paragraph not found");
+                                       }
+
+                                       if (para.getType() == ParagraphType.IMAGE) {
+                                               String zoomStyle = "max-width: 100%;";
+                                               String zoomOption = cookies.get("zoom");
+                                               if (zoomOption != null && !zoomOption.isEmpty()) {
+                                                       if (zoomOption.equals("real")) {
+                                                               zoomStyle = "";
+                                                       } else if (zoomOption.equals("width")) {
+                                                               zoomStyle = "max-width: 100%;";
+                                                       } else if (zoomOption.equals("height")) {
+                                                               // see height of navbar + optionbar
+                                                               zoomStyle = "max-height: calc(100% - 128px);";
+                                                       }
+                                               }
+                                               content = String.format("" //
+                                                               + "<a class='viewer link' href='%s'>" //
+                                                               + "<img class='viewer img' style='%s' src='%s'/>"
+                                                               + "</a>", //
+                                                               next, //
+                                                               zoomStyle, //
+                                                               getStoryUrl(luid, chapter, paragraph));
+                                       } else {
+                                               content = para.getContent();
+                                       }
+
+                               }
+
+                               builder.append(String.format("" //
+                                               + "<div class='bar navbar'>\n" //
+                                               + "\t<a class='button first' href='%s'>&lt;&lt;</a>\n"//
+                                               + "\t<a class='button previous' href='%s'>&lt;</a>\n"//
+                                               + "\t<a class='button next' href='%s'>&gt;</a>\n"//
+                                               + "\t<a class='button last' href='%s'>&gt;&gt;</a>\n"//
+                                               + "</div>\n" //
+                                               + "%s", //
+                                               first, //
+                                               previous, //
+                                               next, //
+                                               last, //
+                                               content //
+                               ));
+
+                               builder.append("<div class='bar optionbar ");
+                               if (paragraph > 0) {
+                                       builder.append("s4");
+                               } else {
+                                       builder.append("s1");
+                               }
+                               builder.append("'>\n");
+                               builder.append(
+                                               "       <a class='button back' href='/'>BACK</a>\n");
+
+                               if (paragraph > 0) {
+                                       builder.append(String.format("" //
+                                                       + "\t<a class='button zoomreal'   href='%s'>REAL</a>\n"//
+                                                       + "\t<a class='button zoomwidth'  href='%s'>WIDTH</a>\n"//
+                                                       + "\t<a class='button zoomheight' href='%s'>HEIGHT</a>\n"//
+                                                       + "</div>\n", //
+                                                       uri + "?optionName=zoom&optionValue=real", //
+                                                       uri + "?optionName=zoom&optionValue=width", //
+                                                       uri + "?optionName=zoom&optionValue=height" //
+                                       ));
+                               }
+                       }
+
+                       appendPostHtml(builder);
+                       return NanoHTTPD.newFixedLengthResponse(Status.OK,
+                                       NanoHTTPD.MIME_HTML, builder.toString());
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler()
+                                       .error(new IOException("Cannot get image: " + uri, e));
+                       return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
+                                       NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
+               }
+       }
+
+       private Response newInputStreamResponse(String mimeType, InputStream in) {
+               if (in == null) {
+                       return NanoHTTPD.newFixedLengthResponse(Status.NO_CONTENT, "",
+                                       null);
+               }
+               return NanoHTTPD.newChunkedResponse(Status.OK, mimeType, in);
+       }
+
+       private String getContentOf(String file) {
+               InputStream in = IOUtils.openResource(WebLibraryServerIndex.class,
+                               file);
+               if (in != null) {
+                       try {
+                               return IOUtils.readSmallStream(in);
+                       } catch (IOException e) {
+                               Instance.getInstance().getTraceHandler().error(
+                                               new IOException("Cannot get file: index.pre.html", e));
+                       }
+               }
+
+               return "";
+       }
+
+       private String getViewUrl(String luid, int chap, Integer para) {
+               return VIEWER_URL //
+                               .replace("{luid}", luid) //
+                               .replace("{chap}", Integer.toString(chap)) //
+                               .replace("/{para}",
+                                               para == null ? "" : "/" + Integer.toString(para));
+       }
+
+       private String getStoryUrl(String luid, int chap, Integer para) {
+               return STORY_URL //
+                               .replace("{luid}", luid) //
+                               .replace("{chap}", Integer.toString(chap)) //
+                               .replace("{para}", para == null ? "" : Integer.toString(para));
+       }
+
+       private String getStoryUrlCover(String luid) {
+               return STORY_URL_COVER //
+                               .replace("{luid}", luid);
+       }
+
+       private MetaData meta(String luid, List<String> whitelist)
+                       throws IOException {
+               BasicLibrary lib = Instance.getInstance().getLibrary();
+               MetaData meta = lib.getInfo(luid);
+               if (!whitelist.isEmpty() && !whitelist.contains(meta.getSource())) {
+                       return null;
+               }
+
+               return meta;
+       }
+
+       private Image getCover(String luid, List<String> whitelist)
+                       throws IOException {
+               MetaData meta = meta(luid, whitelist);
+               if (meta != null) {
+                       return meta.getCover();
+               }
+
+               return null;
+       }
+
+       // NULL if not whitelist OK or if not found
+       private Story story(String luid, List<String> whitelist)
+                       throws IOException {
+               synchronized (storyCache) {
+                       if (storyCache.containsKey(luid)) {
+                               Story story = storyCache.get(luid);
+                               if (!whitelist.isEmpty()
+                                               && !whitelist.contains(story.getMeta().getSource())) {
+                                       return null;
+                               }
+
+                               return story;
+                       }
+               }
+
+               Story story = null;
+               MetaData meta = meta(luid, whitelist);
+               if (meta != null) {
+                       BasicLibrary lib = Instance.getInstance().getLibrary();
+                       story = lib.getStory(luid, null);
+                       long size = sizeOf(story);
+
+                       synchronized (storyCache) {
+                               // Could have been added by another request
+                               if (!storyCache.containsKey(luid)) {
+                                       while (!storyCacheOrder.isEmpty()
+                                                       && storyCacheSize + size > maxStoryCacheSize) {
+                                               String oldestLuid = storyCacheOrder.removeFirst();
+                                               Story oldestStory = storyCache.remove(oldestLuid);
+                                               maxStoryCacheSize -= sizeOf(oldestStory);
+                                       }
+
+                                       storyCacheOrder.add(luid);
+                                       storyCache.put(luid, story);
+                               }
+                       }
+               }
+
+               return story;
+       }
+
+       private long sizeOf(Story story) {
+               long size = 0;
+               for (Chapter chap : story) {
+                       for (Paragraph para : chap) {
+                               if (para.getType() == ParagraphType.IMAGE) {
+                                       size += para.getContentImage().getSize();
+                               } else {
+                                       size += para.getContent().length();
+                               }
+                       }
+               }
+
+               return size;
+       }
+
+       private void appendPreHtml(StringBuilder builder, boolean banner) {
+               String favicon = "favicon.ico";
+               String icon = Instance.getInstance().getUiConfig()
+                               .getString(UiConfig.PROGRAM_ICON);
+               if (icon != null) {
+                       favicon = "icon_" + icon.replace("-", "_") + ".png";
+               }
+
+               builder.append(
+                               getContentOf("index.pre.html").replace("favicon.ico", favicon));
+
+               if (banner) {
+                       builder.append("<div class='banner'>\n");
+                       builder.append("\t<img class='ico' src='") //
+                                       .append(favicon) //
+                                       .append("'/>\n");
+                       builder.append("\t<h1>Fanfix</h1>\n");
+                       builder.append("\t<h2>") //
+                                       .append(Version.getCurrentVersion()) //
+                                       .append("</h2>\n");
+                       builder.append("</div>\n");
+               }
+       }
+
+       private void appendPostHtml(StringBuilder builder) {
+               builder.append(getContentOf("index.post.html"));
+       }
+
+       private void appendOption(StringBuilder builder, int depth, String name,
+                       String value, String selected) {
+               for (int i = 0; i < depth; i++) {
+                       builder.append("\t");
+               }
+               builder.append("<option value='").append(value).append("'");
+               if (value.equals(selected)) {
+                       builder.append(" selected='selected'");
+               }
+               builder.append(">").append(name).append("</option>\n");
+       }
+}
diff --git a/src/be/nikiroo/fanfix/library/web/WebLibraryServerIndex.java b/src/be/nikiroo/fanfix/library/web/WebLibraryServerIndex.java
new file mode 100644 (file)
index 0000000..15c371b
--- /dev/null
@@ -0,0 +1,4 @@
+package be.nikiroo.fanfix.library.web;
+
+public class WebLibraryServerIndex {
+}
diff --git a/src/be/nikiroo/fanfix/library/web/actual_size-32x32.png b/src/be/nikiroo/fanfix/library/web/actual_size-32x32.png
new file mode 100644 (file)
index 0000000..0d356b7
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/actual_size-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/actual_size-64x64.png b/src/be/nikiroo/fanfix/library/web/actual_size-64x64.png
new file mode 100644 (file)
index 0000000..474afda
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/actual_size-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png
new file mode 100644 (file)
index 0000000..e163b60
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_left-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png
new file mode 100644 (file)
index 0000000..e66dd93
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_left-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png
new file mode 100644 (file)
index 0000000..297dcab
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_right-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png
new file mode 100644 (file)
index 0000000..52226f6
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_double_right-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png
new file mode 100644 (file)
index 0000000..af83425
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_left-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png
new file mode 100644 (file)
index 0000000..44cba9c
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_left-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png b/src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png
new file mode 100644 (file)
index 0000000..2540b62
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_right-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png b/src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png
new file mode 100644 (file)
index 0000000..ab960b3
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/arrow_right-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/avicon.ico b/src/be/nikiroo/fanfix/library/web/avicon.ico
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/be/nikiroo/fanfix/library/web/back-32x32.png b/src/be/nikiroo/fanfix/library/web/back-32x32.png
new file mode 100644 (file)
index 0000000..46c19dc
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/back-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/back-64x64.png b/src/be/nikiroo/fanfix/library/web/back-64x64.png
new file mode 100644 (file)
index 0000000..d58d004
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/back-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/favicon.ico b/src/be/nikiroo/fanfix/library/web/favicon.ico
new file mode 100644 (file)
index 0000000..feedaf0
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/favicon.ico differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png b/src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png
new file mode 100644 (file)
index 0000000..727dec2
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_height-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png b/src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png
new file mode 100644 (file)
index 0000000..cec7da4
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_height-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png b/src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png
new file mode 100644 (file)
index 0000000..ee90843
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_width-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png b/src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png
new file mode 100644 (file)
index 0000000..7b897d2
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/fit_to_width-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_alternative.png b/src/be/nikiroo/fanfix/library/web/icon_alternative.png
new file mode 100644 (file)
index 0000000..4ab0957
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_alternative.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_default.png b/src/be/nikiroo/fanfix/library/web/icon_default.png
new file mode 100644 (file)
index 0000000..983b344
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_default.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_magic_book.png b/src/be/nikiroo/fanfix/library/web/icon_magic_book.png
new file mode 100644 (file)
index 0000000..1798dd3
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_magic_book.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_pony_book.png b/src/be/nikiroo/fanfix/library/web/icon_pony_book.png
new file mode 100644 (file)
index 0000000..fb6fe0d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_pony_book.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/icon_pony_library.png b/src/be/nikiroo/fanfix/library/web/icon_pony_library.png
new file mode 100644 (file)
index 0000000..a56a4d2
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/icon_pony_library.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/index.post.html b/src/be/nikiroo/fanfix/library/web/index.post.html
new file mode 100644 (file)
index 0000000..d4e0905
--- /dev/null
@@ -0,0 +1,2 @@
+</div>
+</body>
\ No newline at end of file
diff --git a/src/be/nikiroo/fanfix/library/web/index.pre.html b/src/be/nikiroo/fanfix/library/web/index.pre.html
new file mode 100644 (file)
index 0000000..18c1508
--- /dev/null
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+<!--
+       Copyright 2020 David ROULET
+       
+       This file is part of fanfix.
+       
+       fanfix is free software: you can redistribute it and/or modify
+       it under the terms of the GNU Affero General Public License as published by
+       the Free Software Foundation, either version 3 of the License, or
+       (at your option) any later version.
+       
+       fanfix is distributed in the hope that it will be useful,
+       but WITHOUT ANY WARRANTY; without even the implied warranty of
+       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+       GNU Affero General Public License for more details.
+       
+       You should have received a copy of the GNU Affero General Public License
+       along with fanfix.  If not, see <https://www.gnu.org/licenses/>.
+       ___________________________________________________________________________
+
+       This website was coded by:
+                       A kangaroo.
+                                                  _  _
+                                                 (\\( \
+                                                  `.\-.)
+                              _...._            _,-"   `-.
+\                           ,"      `-._.- -.,-"       .  \
+ \`.                      ,"                               `.
+  \ `-...__              /                           .   .:  y
+   `._     ``-...__     /                           ,"```-._/
+      `-._         ```-"                      |    /_          //
+          `.._                   _            ;   <_ \        //
+              ``-.___             `.           `-._ \ \      //
+                     `- <           `.     (\ _/)/ `.\/     //
+                         \            \     `       ^^^^^^^^^
+       ___________________________________________________________________________
+       
+       -->
+       <meta http-equiv="content-type" content="text/html; charset=utf-8">
+       <meta name="viewport" content="width=device-width, initial-scale=1.0">
+       <title>Fanfix</title>
+       <link rel="stylesheet" type="text/css" href="/style.css" />
+       <link rel="icon" type="image/x-icon" href="/favicon.ico" />
+</head>
+<body>
+<div class='main'>
diff --git a/src/be/nikiroo/fanfix/library/web/search-32x32.png b/src/be/nikiroo/fanfix/library/web/search-32x32.png
new file mode 100644 (file)
index 0000000..92b716d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/search-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/search-64x64.png b/src/be/nikiroo/fanfix/library/web/search-64x64.png
new file mode 100644 (file)
index 0000000..93dbf6d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/search-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/style.css b/src/be/nikiroo/fanfix/library/web/style.css
new file mode 100644 (file)
index 0000000..c520d78
--- /dev/null
@@ -0,0 +1,196 @@
+html, body, .main {
+       margin: 0;
+       padding: 0;
+       font-family : Verdana, "Bitstream Vera Sans", "DejaVu Sans", Tahoma, Geneva, Arial, Sans-serif;
+       font-size: 12px;
+       DISABLED_color: #635c4a;
+       height: 100%;
+}
+
+table {
+       width: 100%;
+}
+
+.banner {
+}
+
+.banner .ico {
+       display: block;
+       height: 50px;
+       float: left;
+       padding: 10px;
+}
+
+.banner h1, .banner h2 {
+}
+
+.main {
+       display: block;
+}
+
+.message {
+        background-color: #ddffdd;
+        border: 1px solid #88dd88;
+        clear: left;
+        border-radius: 5px;
+        padding: 5px;
+        margin: 10px;
+}
+
+.error {
+        background-color: #ffdddd;
+        border: 1px solid #dd8888;
+        clear: left;
+        border-radius: 5px;
+        padding: 5px;
+        margin: 10px;
+}
+
+/* all links and clickable should show a pointer cursor */
+[onclick], h2[onclick]:before, h3[onclick]:before {
+       cursor: pointer;
+}
+
+a:hover {
+       background-color: rgb(225, 225, 225);
+}
+
+h2 {
+       border-bottom: 1px solid #AAA391;
+}
+
+h3 {
+       border-bottom: 1px solid #AAA391;
+       margin-left: 20px;
+}
+
+.login {
+       width: 250px;
+       display: flex;
+       margin: auto;
+       margin-top: 200px;
+       flex-direction: column;
+       border: 1px solid gray;
+       padding: 20px;
+       border-radius: 10px;
+}
+
+.login input {
+       margin: 5px;
+       min-height: 22px;
+}
+
+.login input[type='submit'] {
+       margin-top: 15px;
+}
+
+.breadcrumbs {
+}
+
+.filter {
+       padding: 10px;
+}
+
+.books {
+}
+
+.book_line {
+       width: 100%;
+       display: flex;
+}
+
+.book_line .link, .book_line .title {
+       flex-grow: 100;
+       padding-right: 5px;
+       padding-left: 5px;
+}
+
+.book_line .link {
+       text-decoration: none;
+}
+
+.book_line .cache_icon {
+       color: green;
+}
+
+.book_line .luid {
+       color: gray;
+       padding-right: 10px;
+       padding-left: 10px;
+}
+
+.book_line .title {
+       color: initial;
+}
+
+.book_line .author {
+       float: right;
+       color: blue;
+}
+
+.bar {
+       height: 64px;
+       width: 100%;
+       display: block;
+       background: white;
+       position: fixed;
+}
+
+.viewer {
+       padding-top: 64px;
+       padding-bottom: 64px;
+}
+
+a.viewer.link:hover {
+       background-color: transparent;
+}
+
+.viewer.text {
+       padding-left: 10px;
+       padding-right: 10px;
+}
+
+.bar.navbar {
+       padding-left: calc(50% - (4 * 64px / 2));
+}
+
+.bar.optionbar {
+       bottom: 0;      
+}
+
+.bar.optionbar.s1 {
+       padding-left: calc(50% - (1 * 64px / 2));
+}
+
+.bar.optionbar.s4 {
+       padding-left: calc(50% - (4 * 64px / 2));
+}
+
+.bar .button {
+       height: 54px;
+       width: 54px;
+       line-height: 64px;
+       display: inline-block;
+       text-align: center;
+       color: transparent;
+       text-decoration: none;
+       background-position: center;
+       background-repeat: no-repeat;
+       border-radius: 5px;
+       border: 1px solid #bac2e1;
+       margin: 5px;
+}
+
+.bar .button:hover {
+       background-color: bac2e1;
+}
+
+.bar .button.first    { background-image: url('/arrow_double_left-32x32.png');  }
+.bar .button.previous { background-image: url('/arrow_left-32x32.png');         }
+.bar .button.next     { background-image: url('/arrow_right-32x32.png');        }
+.bar .button.last     { background-image: url('/arrow_double_right-32x32.png'); }
+
+.bar .button.back       { background-image: url('/back-32x32.png');          }
+.bar .button.zoomreal   { background-image: url('/actual_size-32x32.png');   }
+.bar .button.zoomwidth  { background-image: url('/fit_to_width-32x32.png');  }
+.bar .button.zoomheight { background-image: url('/fit_to_height-32x32.png'); }
diff --git a/src/be/nikiroo/fanfix/library/web/unknown-32x32.png b/src/be/nikiroo/fanfix/library/web/unknown-32x32.png
new file mode 100644 (file)
index 0000000..d2315d1
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/unknown-32x32.png differ
diff --git a/src/be/nikiroo/fanfix/library/web/unknown-64x64.png b/src/be/nikiroo/fanfix/library/web/unknown-64x64.png
new file mode 100644 (file)
index 0000000..261889d
Binary files /dev/null and b/src/be/nikiroo/fanfix/library/web/unknown-64x64.png differ
diff --git a/src/be/nikiroo/fanfix/reader/TextOutput.java b/src/be/nikiroo/fanfix/reader/TextOutput.java
new file mode 100644 (file)
index 0000000..60b3a7f
--- /dev/null
@@ -0,0 +1,148 @@
+package be.nikiroo.fanfix.reader;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import be.nikiroo.fanfix.Instance;
+import be.nikiroo.fanfix.data.Chapter;
+import be.nikiroo.fanfix.data.Paragraph;
+import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
+import be.nikiroo.fanfix.data.Story;
+import be.nikiroo.fanfix.output.BasicOutput;
+
+/**
+ * This class can export a chapter into HTML3 code ready for Java Swing support.
+ * 
+ * @author niki
+ */
+public class TextOutput {
+       private StringBuilder builder;
+       private BasicOutput output;
+       private Story fakeStory;
+       private boolean chapterName;
+
+       /**
+        * Create a new {@link TextOutput} that will convert a {@link Chapter} into
+        * HTML3 suited for Java Swing.
+        * 
+        * @param standalone
+        *            TRUE if you want a standalone document (with an <HTML> tag)
+        */
+       public TextOutput(final boolean standalone) {
+               builder = new StringBuilder();
+               fakeStory = new Story();
+
+               output = new BasicOutput() {
+                       private boolean paraInQuote;
+
+                       @Override
+                       protected void writeChapterHeader(Chapter chap) throws IOException {
+                               if (standalone) {
+                                       builder.append("<HTML style='line-height: 5px;'>");
+                               }
+
+                               if (chapterName) {
+                                       builder.append("<H1>");
+                                       builder.append("Chapter ");
+                                       builder.append(chap.getNumber());
+                                       if (chap.getName() != null
+                                                       && !chap.getName().trim().isEmpty()) {
+                                               builder.append(": ");
+                                               builder.append(chap.getName());
+                                       }
+                                       builder.append("</H1>");
+                               }
+
+                               builder.append("<DIV align='justify'>");
+                       }
+
+                       @Override
+                       protected void writeChapterFooter(Chapter chap) throws IOException {
+                               if (paraInQuote) {
+                                       builder.append("</DIV>");
+                               }
+                               paraInQuote = false;
+
+                               builder.append("</DIV>");
+
+                               if (standalone) {
+                                       builder.append("</HTML>");
+                               }
+                       }
+
+                       @Override
+                       protected void writeParagraph(Paragraph para) throws IOException {
+                               if ((para.getType() == ParagraphType.QUOTE) == !paraInQuote) {
+                                       paraInQuote = !paraInQuote;
+                                       if (paraInQuote) {
+                                               builder.append("<BR>");
+                                               builder.append("<DIV>");
+                                       } else {
+                                               builder.append("</DIV>");
+                                               builder.append("<BR>");
+                                       }
+                               }
+
+                               switch (para.getType()) {
+                               case NORMAL:
+                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+                                       builder.append(decorateText(para.getContent()));
+                                       builder.append("<BR>");
+                                       break;
+                               case BLANK:
+                                       builder.append("<FONT SIZE='1'><BR></FONT>");
+                                       break;
+                               case BREAK:
+                                       // Used to be 7777DD
+                                       builder.append("<P COLOR='#AAAAAA' ALIGN='CENTER'>");
+                                       builder.append("<FONT SIZE='5'>* * *</FONT>");
+                                       builder.append("</P>");
+                                       builder.append("<BR>");
+                                       break;
+                               case QUOTE:
+                                       builder.append("<DIV>");
+                                       builder.append("&nbsp;&nbsp;&nbsp;&nbsp;");
+                                       builder.append("&mdash;&nbsp;");
+                                       builder.append(decorateText(para.getContent()));
+                                       builder.append("</DIV>");
+
+                                       break;
+                               case IMAGE:
+                               }
+                       }
+
+                       @Override
+                       protected String enbold(String word) {
+                               // Used to be COLOR='#7777DD'
+                               return "<B>" + word + "</B>";
+                       }
+
+                       @Override
+                       protected String italize(String word) {
+                               return "<I COLOR='GRAY'>" + word + "</I>";
+                       }
+               };
+       }
+
+       /**
+        * Convert the chapter into HTML3 code.
+        * 
+        * @param chap
+        *            the {@link Chapter} to convert
+        * @param chapterName
+        *            display the chapter name
+        * 
+        * @return HTML3 code tested with Java Swing
+        */
+       public String convert(Chapter chap, boolean chapterName) {
+               this.chapterName = chapterName;
+               builder.setLength(0);
+               try {
+                       fakeStory.setChapters(Arrays.asList(chap));
+                       output.process(fakeStory, null, null);
+               } catch (IOException e) {
+                       Instance.getInstance().getTraceHandler().error(e);
+               }
+               return builder.toString();
+       }
+}
diff --git a/src/be/nikiroo/utils/CookieUtils.java b/src/be/nikiroo/utils/CookieUtils.java
new file mode 100644 (file)
index 0000000..f082026
--- /dev/null
@@ -0,0 +1,57 @@
+package be.nikiroo.utils;
+
+import java.util.Date;
+
+public class CookieUtils {
+       /**
+        * The number of seconds for the period (we accept the current or the
+        * previous period as valid for a cookie, via "offset").
+        */
+       static public int GRACE_PERIOD = 3600 * 1000; // between 1 and 2h
+
+       /**
+        * Generate a new cookie value from the user (email) and an offset.
+        * <p>
+        * You should use an offset of "0" when creating the cookie, and an offset
+        * of "0" or "-1" if required when checking for the value (the idea is to
+        * allow a cookie to persist across two timespans; if not, the cookie will
+        * be expired the very second we switch to a new timespan).
+        * 
+        * @param value
+        *            the value to generate a cookie for -- you must be able to
+        *            regenerate it in order to check it later
+        * @param offset
+        *            the offset (should be 0 for creating, 0 then -1 if needed for
+        *            checking)
+        * 
+        * @return the new cookie
+        */
+       static public String generateCookie(String value, int offset) {
+               long unixTime = (long) Math.floor(new Date().getTime() / GRACE_PERIOD)
+                               + offset;
+               return HashUtils.sha512(value + Long.toString(unixTime));
+       }
+
+       /**
+        * Check the given cookie.
+        * 
+        * @param value
+        *            the value to generate a cookie for -- you must be able to
+        *            regenerate it in order to check it later
+        * @param cookie
+        *            the cookie to validate
+        * 
+        * @return TRUE if it is correct
+        */
+       static public boolean validateCookie(String value, String cookie) {
+               if (cookie != null)
+                       cookie = cookie.trim();
+
+               String newCookie = generateCookie(value, 0);
+               if (!newCookie.equals(cookie)) {
+                       newCookie = generateCookie(value, -1);
+               }
+
+               return newCookie.equals(cookie);
+       }
+}
diff --git a/src/be/nikiroo/utils/HashUtils.java b/src/be/nikiroo/utils/HashUtils.java
new file mode 100644 (file)
index 0000000..df8d7c6
--- /dev/null
@@ -0,0 +1,89 @@
+package be.nikiroo.utils;
+
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Small class to easily hash some values in a few different ways.
+ * <p>
+ * Does <b>not</b> handle the salt itself, you have to add it yourself.
+ * 
+ * @author niki
+ */
+public class HashUtils {
+       /**
+        * Hash the given value.
+        * 
+        * @param value
+        *            the value to hash
+        * 
+        * @return the hash that can be used to confirm a value
+        * 
+        * @throws RuntimeException
+        *             if UTF-8 support is not available (!) or SHA-512 support is
+        *             not available
+        * @throws NullPointerException
+        *             if email or pass is NULL
+        */
+       static public String sha512(String value) {
+               return hash("SHA-512", value);
+       }
+
+       /**
+        * Hash the given value.
+        * 
+        * @param value
+        *            the value to hash
+        * 
+        * @return the hash that can be used to confirm the a value
+        * 
+        * @throws RuntimeException
+        *             if UTF-8 support is not available (!) or MD5 support is not
+        *             available
+        * @throws NullPointerException
+        *             if email or pass is NULL
+        */
+       static public String md5(String value) {
+               return hash("MD5", value);
+       }
+
+       /**
+        * Hash the given value.
+        * 
+        * @param algo
+        *            the hash algorithm to use ("MD5" and "SHA-512" are supported)
+        * @param value
+        *            the value to hash
+        * 
+        * @return the hash that can be used to confirm a value
+        * 
+        * @throws RuntimeException
+        *             if UTF-8 support is not available (!) or the algorithm
+        *             support is not available
+        * @throws NullPointerException
+        *             if email or pass is NULL
+        */
+       static private String hash(String algo, String value) {
+               try {
+                       MessageDigest md = MessageDigest.getInstance(algo);
+                       md.update(value.getBytes("UTF-8"));
+                       byte byteData[] = md.digest();
+
+                       StringBuffer hexString = new StringBuffer();
+                       for (int i = 0; i < byteData.length; i++) {
+                               String hex = Integer.toHexString(0xff & byteData[i]);
+                               if (hex.length() % 2 == 1)
+                                       hexString.append('0');
+                               hexString.append(hex);
+                       }
+
+                       return hexString.toString();
+               } catch (NoSuchAlgorithmException e) {
+                       throw new RuntimeException(algo + " hashing not available", e);
+               } catch (UnsupportedEncodingException e) {
+                       throw new RuntimeException(
+                                       "UTF-8 encoding is required in a compatible JVM", e);
+               }
+       }
+}
diff --git a/src/be/nikiroo/utils/NanoHTTPD.java b/src/be/nikiroo/utils/NanoHTTPD.java
new file mode 100644 (file)
index 0000000..8d183c1
--- /dev/null
@@ -0,0 +1,2358 @@
+package be.nikiroo.utils;
+
+/*
+ * #%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.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.DataOutput;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.io.RandomAccessFile;
+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.URL;
+import java.net.URLDecoder;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.security.KeyStore;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Properties;
+import java.util.StringTokenizer;
+import java.util.TimeZone;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPOutputStream;
+
+import javax.net.ssl.KeyManager;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLServerSocketFactory;
+import javax.net.ssl.TrustManagerFactory;
+
+import be.nikiroo.utils.NanoHTTPD.Response.IStatus;
+import be.nikiroo.utils.NanoHTTPD.Response.Status;
+
+/**
+ * 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 {
+
+    /**
+     * Pluggable strategy for asynchronously executing requests.
+     */
+    public interface AsyncRunner {
+
+        void closeAll();
+
+        void closed(ClientHandler clientHandler);
+
+        void exec(ClientHandler code);
+    }
+
+    /**
+     * The runnable that will be used for every new client connection.
+     */
+    public class ClientHandler implements Runnable {
+
+        private final InputStream inputStream;
+
+        private final Socket acceptSocket;
+
+        public ClientHandler(InputStream inputStream, Socket acceptSocket) {
+            this.inputStream = inputStream;
+            this.acceptSocket = acceptSocket;
+        }
+
+        public void close() {
+            safeClose(this.inputStream);
+            safeClose(this.acceptSocket);
+        }
+
+        @Override
+        public void run() {
+            OutputStream outputStream = null;
+            try {
+                outputStream = this.acceptSocket.getOutputStream();
+                TempFileManager tempFileManager = NanoHTTPD.this.tempFileManagerFactory.create();
+                HTTPSession session = new HTTPSession(tempFileManager, this.inputStream, outputStream, this.acceptSocket.getInetAddress());
+                while (!this.acceptSocket.isClosed()) {
+                    session.execute();
+                }
+            } catch (Exception e) {
+                // When the socket is closed by the client,
+                // we throw our own SocketException
+                // to break the "keep alive" loop above. If
+                // the exception was anything other
+                // than the expected SocketException OR a
+                // SocketTimeoutException, print the
+                // stacktrace
+                if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage())) && !(e instanceof SocketTimeoutException)) {
+                    NanoHTTPD.LOG.log(Level.SEVERE, "Communication with the client broken, or an bug in the handler code", e);
+                }
+            } finally {
+                safeClose(outputStream);
+                safeClose(this.inputStream);
+                safeClose(this.acceptSocket);
+                NanoHTTPD.this.asyncRunner.closed(this);
+            }
+        }
+    }
+
+    public static class Cookie {
+
+        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());
+        }
+
+        private final String n, v, e;
+
+        public Cookie(String name, String value) {
+            this(name, value, 30);
+        }
+
+        public Cookie(String name, String value, int numDays) {
+            this.n = name;
+            this.v = value;
+            this.e = getHTTPTime(numDays);
+        }
+
+        public Cookie(String name, String value, String expires) {
+            this.n = name;
+            this.v = value;
+            this.e = expires;
+        }
+
+        public String getHTTPHeader() {
+            String fmt = "%s=%s; expires=%s";
+            return String.format(fmt, this.n, this.v, this.e);
+        }
+    }
+
+    /**
+     * 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 final HashMap<String, String> cookies = new HashMap<String, String>();
+
+        private final 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) {
+                        this.cookies.put(data[0], data[1]);
+                    }
+                }
+            }
+        }
+
+        /**
+         * 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);
+        }
+
+        @Override
+        public Iterator<String> iterator() {
+            return this.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 this.cookies.get(name);
+        }
+
+        public void set(Cookie cookie) {
+            this.queue.add(cookie);
+        }
+
+        /**
+         * Sets a cookie.
+         * 
+         * @param name
+         *            The cookie's name.
+         * @param value
+         *            The cookie's value.
+         * @param expires
+         *            How many days until the cookie expires.
+         */
+        public void set(String name, String value, int expires) {
+            this.queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires)));
+        }
+
+        /**
+         * Internally used by the webserver to add all queued cookies into the
+         * Response's HTTP Headers.
+         * 
+         * @param response
+         *            The Response object to which headers the queued cookies
+         *            will be added.
+         */
+        public void unloadQueue(Response response) {
+            for (Cookie cookie : this.queue) {
+                response.addHeader("Set-Cookie", cookie.getHTTPHeader());
+            }
+        }
+    }
+
+    /**
+     * 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;
+
+        private final List<ClientHandler> running = Collections.synchronizedList(new ArrayList<NanoHTTPD.ClientHandler>());
+
+        /**
+         * @return a list with currently running clients.
+         */
+        public List<ClientHandler> getRunning() {
+            return running;
+        }
+
+        @Override
+        public void closeAll() {
+            // copy of the list for concurrency
+            for (ClientHandler clientHandler : new ArrayList<ClientHandler>(this.running)) {
+                clientHandler.close();
+            }
+        }
+
+        @Override
+        public void closed(ClientHandler clientHandler) {
+            this.running.remove(clientHandler);
+        }
+
+        @Override
+        public void exec(ClientHandler clientHandler) {
+            ++this.requestCount;
+            Thread t = new Thread(clientHandler);
+            t.setDaemon(true);
+            t.setName("NanoHttpd Request Processor (#" + this.requestCount + ")");
+            this.running.add(clientHandler);
+            t.start();
+        }
+    }
+
+    /**
+     * Default strategy for creating and cleaning up temporary files.
+     * <p/>
+     * <p>
+     * By default, files are created by <code>File.createTempFile()</code> in
+     * the directory specified.
+     * </p>
+     */
+    public static class DefaultTempFile implements TempFile {
+
+        private final File file;
+
+        private final OutputStream fstream;
+
+        public DefaultTempFile(File tempdir) throws IOException {
+            this.file = File.createTempFile("NanoHTTPD-", "", tempdir);
+            this.fstream = new FileOutputStream(this.file);
+        }
+
+        @Override
+        public void delete() throws Exception {
+            safeClose(this.fstream);
+            if (!this.file.delete()) {
+                throw new Exception("could not delete temporary file: " + this.file.getAbsolutePath());
+            }
+        }
+
+        @Override
+        public String getName() {
+            return this.file.getAbsolutePath();
+        }
+
+        @Override
+        public OutputStream open() throws Exception {
+            return this.fstream;
+        }
+    }
+
+    /**
+     * Default strategy for creating and cleaning up temporary files.
+     * <p/>
+     * <p>
+     * This class stores its files in the standard location (that is, wherever
+     * <code>java.io.tmpdir</code> points to). Files are added to an internal
+     * list, and deleted when no longer needed (that is, when
+     * <code>clear()</code> is invoked at the end of processing a request).
+     * </p>
+     */
+    public static class DefaultTempFileManager implements TempFileManager {
+
+        private final File tmpdir;
+
+        private final List<TempFile> tempFiles;
+
+        public DefaultTempFileManager() {
+            this.tmpdir = new File(System.getProperty("java.io.tmpdir"));
+            if (!tmpdir.exists()) {
+                tmpdir.mkdirs();
+            }
+            this.tempFiles = new ArrayList<TempFile>();
+        }
+
+        @Override
+        public void clear() {
+            for (TempFile file : this.tempFiles) {
+                try {
+                    file.delete();
+                } catch (Exception ignored) {
+                    NanoHTTPD.LOG.log(Level.WARNING, "could not delete file ", ignored);
+                }
+            }
+            this.tempFiles.clear();
+        }
+
+        @Override
+        public TempFile createTempFile(String filename_hint) throws Exception {
+            DefaultTempFile tempFile = new DefaultTempFile(this.tmpdir);
+            this.tempFiles.add(tempFile);
+            return tempFile;
+        }
+    }
+
+    /**
+     * Default strategy for creating and cleaning up temporary files.
+     */
+    private class DefaultTempFileManagerFactory implements TempFileManagerFactory {
+
+        @Override
+        public TempFileManager create() {
+            return new DefaultTempFileManager();
+        }
+    }
+
+    /**
+     * Creates a normal ServerSocket for TCP connections
+     */
+    public static class DefaultServerSocketFactory implements ServerSocketFactory {
+
+        @Override
+        public ServerSocket create() throws IOException {
+            return new ServerSocket();
+        }
+
+    }
+
+    /**
+     * Creates a new SSLServerSocket
+     */
+    public static class SecureServerSocketFactory implements ServerSocketFactory {
+
+        private SSLServerSocketFactory sslServerSocketFactory;
+
+        private String[] sslProtocols;
+
+        public SecureServerSocketFactory(SSLServerSocketFactory sslServerSocketFactory, String[] sslProtocols) {
+            this.sslServerSocketFactory = sslServerSocketFactory;
+            this.sslProtocols = sslProtocols;
+        }
+
+        @Override
+        public ServerSocket create() throws IOException {
+            SSLServerSocket ss = null;
+            ss = (SSLServerSocket) this.sslServerSocketFactory.createServerSocket();
+            if (this.sslProtocols != null) {
+                ss.setEnabledProtocols(this.sslProtocols);
+            } else {
+                ss.setEnabledProtocols(ss.getSupportedProtocols());
+            }
+            ss.setUseClientMode(false);
+            ss.setWantClientAuth(false);
+            ss.setNeedClientAuth(false);
+            return ss;
+        }
+
+    }
+
+    private static final String CONTENT_DISPOSITION_REGEX = "([ |\t]*Content-Disposition[ |\t]*:)(.*)";
+
+    private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern.compile(CONTENT_DISPOSITION_REGEX, Pattern.CASE_INSENSITIVE);
+
+    private static final String CONTENT_TYPE_REGEX = "([ |\t]*content-type[ |\t]*:)(.*)";
+
+    private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile(CONTENT_TYPE_REGEX, Pattern.CASE_INSENSITIVE);
+
+    private static final String CONTENT_DISPOSITION_ATTRIBUTE_REGEX = "[ |\t]*([a-zA-Z]*)[ |\t]*=[ |\t]*['|\"]([^\"^']*)['|\"]";
+
+    private static final Pattern CONTENT_DISPOSITION_ATTRIBUTE_PATTERN = Pattern.compile(CONTENT_DISPOSITION_ATTRIBUTE_REGEX);
+
+    protected static class ContentType {
+
+        private static final String ASCII_ENCODING = "US-ASCII";
+
+        private static final String MULTIPART_FORM_DATA_HEADER = "multipart/form-data";
+
+        private static final String CONTENT_REGEX = "[ |\t]*([^/^ ^;^,]+/[^ ^;^,]+)";
+
+        private static final Pattern MIME_PATTERN = Pattern.compile(CONTENT_REGEX, Pattern.CASE_INSENSITIVE);
+
+        private static final String CHARSET_REGEX = "[ |\t]*(charset)[ |\t]*=[ |\t]*['|\"]?([^\"^'^;^,]*)['|\"]?";
+
+        private static final Pattern CHARSET_PATTERN = Pattern.compile(CHARSET_REGEX, Pattern.CASE_INSENSITIVE);
+
+        private static final String BOUNDARY_REGEX = "[ |\t]*(boundary)[ |\t]*=[ |\t]*['|\"]?([^\"^'^;^,]*)['|\"]?";
+
+        private static final Pattern BOUNDARY_PATTERN = Pattern.compile(BOUNDARY_REGEX, Pattern.CASE_INSENSITIVE);
+
+        private final String contentTypeHeader;
+
+        private final String contentType;
+
+        private final String encoding;
+
+        private final String boundary;
+
+        public ContentType(String contentTypeHeader) {
+            this.contentTypeHeader = contentTypeHeader;
+            if (contentTypeHeader != null) {
+                contentType = getDetailFromContentHeader(contentTypeHeader, MIME_PATTERN, "", 1);
+                encoding = getDetailFromContentHeader(contentTypeHeader, CHARSET_PATTERN, null, 2);
+            } else {
+                contentType = "";
+                encoding = "UTF-8";
+            }
+            if (MULTIPART_FORM_DATA_HEADER.equalsIgnoreCase(contentType)) {
+                boundary = getDetailFromContentHeader(contentTypeHeader, BOUNDARY_PATTERN, null, 2);
+            } else {
+                boundary = null;
+            }
+        }
+
+        private String getDetailFromContentHeader(String contentTypeHeader, Pattern pattern, String defaultValue, int group) {
+            Matcher matcher = pattern.matcher(contentTypeHeader);
+            return matcher.find() ? matcher.group(group) : defaultValue;
+        }
+
+        public String getContentTypeHeader() {
+            return contentTypeHeader;
+        }
+
+        public String getContentType() {
+            return contentType;
+        }
+
+        public String getEncoding() {
+            return encoding == null ? ASCII_ENCODING : encoding;
+        }
+
+        public String getBoundary() {
+            return boundary;
+        }
+
+        public boolean isMultipart() {
+            return MULTIPART_FORM_DATA_HEADER.equalsIgnoreCase(contentType);
+        }
+
+        public ContentType tryUTF8() {
+            if (encoding == null) {
+                return new ContentType(this.contentTypeHeader + "; charset=UTF-8");
+            }
+            return this;
+        }
+    }
+
+    protected class HTTPSession implements IHTTPSession {
+
+        private static final int REQUEST_BUFFER_LEN = 512;
+
+        private static final int MEMORY_STORE_LIMIT = 1024;
+
+        public static final int BUFSIZE = 8192;
+
+        public static final int MAX_HEADER_SIZE = 1024;
+
+        private final TempFileManager tempFileManager;
+
+        private final OutputStream outputStream;
+
+        private final BufferedInputStream inputStream;
+
+        private int splitbyte;
+
+        private int rlen;
+
+        private String uri;
+
+        private Method method;
+
+        private Map<String, List<String>> parms;
+
+        private Map<String, String> headers;
+
+        private CookieHandler cookies;
+
+        private String queryParameterString;
+
+        private String remoteIp;
+
+        private String remoteHostname;
+
+        private String protocolVersion;
+
+        public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) {
+            this.tempFileManager = tempFileManager;
+            this.inputStream = new BufferedInputStream(inputStream, HTTPSession.BUFSIZE);
+            this.outputStream = outputStream;
+        }
+
+        public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) {
+            this.tempFileManager = tempFileManager;
+            this.inputStream = new BufferedInputStream(inputStream, HTTPSession.BUFSIZE);
+            this.outputStream = outputStream;
+            this.remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString();
+            this.remoteHostname = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "localhost" : inetAddress.getHostName().toString();
+            this.headers = new HashMap<String, String>();
+        }
+
+        /**
+         * Decodes the sent headers and loads the data into Key/value pairs
+         */
+        private void decodeHeader(BufferedReader in, Map<String, String> pre, Map<String, List<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, 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()) {
+                    protocolVersion = st.nextToken();
+                } else {
+                    protocolVersion = "HTTP/1.1";
+                    NanoHTTPD.LOG.log(Level.FINE, "no protocol version specified, strange. Assuming HTTP/1.1.");
+                }
+                String line = in.readLine();
+                while (line != null && !line.trim().isEmpty()) {
+                    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 decodeMultipartFormData(ContentType contentType, ByteBuffer fbuf, Map<String, List<String>> parms, Map<String, String> files) throws ResponseException {
+            int pcount = 0;
+            try {
+                int[] boundaryIdxs = getBoundaryPositions(fbuf, contentType.getBoundary().getBytes());
+                if (boundaryIdxs.length < 2) {
+                    throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but contains less than two boundary strings.");
+                }
+
+                byte[] partHeaderBuff = new byte[MAX_HEADER_SIZE];
+                for (int boundaryIdx = 0; boundaryIdx < boundaryIdxs.length - 1; boundaryIdx++) {
+                    fbuf.position(boundaryIdxs[boundaryIdx]);
+                    int len = (fbuf.remaining() < MAX_HEADER_SIZE) ? fbuf.remaining() : MAX_HEADER_SIZE;
+                    fbuf.get(partHeaderBuff, 0, len);
+                    BufferedReader in =
+                            new BufferedReader(new InputStreamReader(new ByteArrayInputStream(partHeaderBuff, 0, len), Charset.forName(contentType.getEncoding())), len);
+
+                    int headerLines = 0;
+                    // First line is boundary string
+                    String mpline = in.readLine();
+                    headerLines++;
+                    if (mpline == null || !mpline.contains(contentType.getBoundary())) {
+                        throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but chunk does not start with boundary.");
+                    }
+
+                    String partName = null, fileName = null, partContentType = null;
+                    // Parse the reset of the header lines
+                    mpline = in.readLine();
+                    headerLines++;
+                    while (mpline != null && mpline.trim().length() > 0) {
+                        Matcher matcher = CONTENT_DISPOSITION_PATTERN.matcher(mpline);
+                        if (matcher.matches()) {
+                            String attributeString = matcher.group(2);
+                            matcher = CONTENT_DISPOSITION_ATTRIBUTE_PATTERN.matcher(attributeString);
+                            while (matcher.find()) {
+                                String key = matcher.group(1);
+                                if ("name".equalsIgnoreCase(key)) {
+                                    partName = matcher.group(2);
+                                } else if ("filename".equalsIgnoreCase(key)) {
+                                    fileName = matcher.group(2);
+                                    // add these two line to support multiple
+                                    // files uploaded using the same field Id
+                                    if (!fileName.isEmpty()) {
+                                        if (pcount > 0)
+                                            partName = partName + String.valueOf(pcount++);
+                                        else
+                                            pcount++;
+                                    }
+                                }
+                            }
+                        }
+                        matcher = CONTENT_TYPE_PATTERN.matcher(mpline);
+                        if (matcher.matches()) {
+                            partContentType = matcher.group(2).trim();
+                        }
+                        mpline = in.readLine();
+                        headerLines++;
+                    }
+                    int partHeaderLength = 0;
+                    while (headerLines-- > 0) {
+                        partHeaderLength = scipOverNewLine(partHeaderBuff, partHeaderLength);
+                    }
+                    // Read the part data
+                    if (partHeaderLength >= len - 4) {
+                        throw new ResponseException(Response.Status.INTERNAL_ERROR, "Multipart header size exceeds MAX_HEADER_SIZE.");
+                    }
+                    int partDataStart = boundaryIdxs[boundaryIdx] + partHeaderLength;
+                    int partDataEnd = boundaryIdxs[boundaryIdx + 1] - 4;
+
+                    fbuf.position(partDataStart);
+
+                    List<String> values = parms.get(partName);
+                    if (values == null) {
+                        values = new ArrayList<String>();
+                        parms.put(partName, values);
+                    }
+
+                    if (partContentType == null) {
+                        // Read the part into a string
+                        byte[] data_bytes = new byte[partDataEnd - partDataStart];
+                        fbuf.get(data_bytes);
+
+                        values.add(new String(data_bytes, contentType.getEncoding()));
+                    } else {
+                        // Read it into a file
+                        String path = saveTmpFile(fbuf, partDataStart, partDataEnd - partDataStart, fileName);
+                        if (!files.containsKey(partName)) {
+                            files.put(partName, path);
+                        } else {
+                            int count = 2;
+                            while (files.containsKey(partName + count)) {
+                                count++;
+                            }
+                            files.put(partName + count, path);
+                        }
+                        values.add(fileName);
+                    }
+                }
+            } catch (ResponseException re) {
+                throw re;
+            } catch (Exception e) {
+                throw new ResponseException(Response.Status.INTERNAL_ERROR, e.toString());
+            }
+        }
+
+        private int scipOverNewLine(byte[] partHeaderBuff, int index) {
+            while (partHeaderBuff[index] != '\n') {
+                index++;
+            }
+            return ++index;
+        }
+
+        /**
+         * Decodes parameters in percent-encoded URI-format ( e.g.
+         * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given
+         * Map.
+         */
+        private void decodeParms(String parms, Map<String, List<String>> p) {
+            if (parms == null) {
+                this.queryParameterString = "";
+                return;
+            }
+
+            this.queryParameterString = parms;
+            StringTokenizer st = new StringTokenizer(parms, "&");
+            while (st.hasMoreTokens()) {
+                String e = st.nextToken();
+                int sep = e.indexOf('=');
+                String key = null;
+                String value = null;
+
+                if (sep >= 0) {
+                    key = decodePercent(e.substring(0, sep)).trim();
+                    value = decodePercent(e.substring(sep + 1));
+                } else {
+                    key = decodePercent(e).trim();
+                    value = "";
+                }
+
+                List<String> values = p.get(key);
+                if (values == null) {
+                    values = new ArrayList<String>();
+                    p.put(key, values);
+                }
+
+                values.add(value);
+            }
+        }
+
+        @Override
+        public void execute() throws IOException {
+            Response r = null;
+            try {
+                // Read the first 8192 bytes.
+                // The full header should fit in here.
+                // Apache's default header limit is 8KB.
+                // Do NOT assume that a single read will get the entire header
+                // at once!
+                byte[] buf = new byte[HTTPSession.BUFSIZE];
+                this.splitbyte = 0;
+                this.rlen = 0;
+
+                int read = -1;
+                this.inputStream.mark(HTTPSession.BUFSIZE);
+                try {
+                    read = this.inputStream.read(buf, 0, HTTPSession.BUFSIZE);
+                } catch (SSLException e) {
+                    throw e;
+                } catch (IOException e) {
+                    safeClose(this.inputStream);
+                    safeClose(this.outputStream);
+                    throw new SocketException("NanoHttpd Shutdown");
+                }
+                if (read == -1) {
+                    // socket was been closed
+                    safeClose(this.inputStream);
+                    safeClose(this.outputStream);
+                    throw new SocketException("NanoHttpd Shutdown");
+                }
+                while (read > 0) {
+                    this.rlen += read;
+                    this.splitbyte = findHeaderEnd(buf, this.rlen);
+                    if (this.splitbyte > 0) {
+                        break;
+                    }
+                    read = this.inputStream.read(buf, this.rlen, HTTPSession.BUFSIZE - this.rlen);
+                }
+
+                if (this.splitbyte < this.rlen) {
+                    this.inputStream.reset();
+                    this.inputStream.skip(this.splitbyte);
+                }
+
+                this.parms = new HashMap<String, List<String>>();
+                if (null == this.headers) {
+                    this.headers = new HashMap<String, String>();
+                } else {
+                    this.headers.clear();
+                }
+
+                // Create a BufferedReader for parsing the header.
+                BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, this.rlen)));
+
+                // Decode the header into parms and header java properties
+                Map<String, String> pre = new HashMap<String, String>();
+                decodeHeader(hin, pre, this.parms, this.headers);
+
+                if (null != this.remoteIp) {
+                    this.headers.put("remote-addr", this.remoteIp);
+                    this.headers.put("http-client-ip", this.remoteIp);
+                }
+
+                this.method = Method.lookup(pre.get("method"));
+                if (this.method == null) {
+                    throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error. HTTP verb " + pre.get("method") + " unhandled.");
+                }
+
+                this.uri = pre.get("uri");
+
+                this.cookies = new CookieHandler(this.headers);
+
+                String connection = this.headers.get("connection");
+                boolean keepAlive = "HTTP/1.1".equals(protocolVersion) && (connection == null || !connection.matches("(?i).*close.*"));
+
+                // Ok, now do the serve()
+
+                // TODO: long body_size = getBodySize();
+                // TODO: long pos_before_serve = this.inputStream.totalRead()
+                // (requires implementation for totalRead())
+                r = serve(this);
+                // TODO: this.inputStream.skip(body_size -
+                // (this.inputStream.totalRead() - pos_before_serve))
+
+                if (r == null) {
+                    throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response.");
+                } else {
+                    String acceptEncoding = this.headers.get("accept-encoding");
+                    this.cookies.unloadQueue(r);
+                    r.setRequestMethod(this.method);
+                    r.setGzipEncoding(useGzipWhenAccepted(r) && acceptEncoding != null && acceptEncoding.contains("gzip"));
+                    r.setKeepAlive(keepAlive);
+                    r.send(this.outputStream);
+                }
+                if (!keepAlive || r.isCloseConnection()) {
+                    throw new SocketException("NanoHttpd Shutdown");
+                }
+            } catch (SocketException e) {
+                // throw it out to close socket object (finalAccept)
+                throw e;
+            } catch (SocketTimeoutException ste) {
+                // treat socket timeouts the same way we treat socket exceptions
+                // i.e. close the stream & finalAccept object by throwing the
+                // exception up the call stack.
+                throw ste;
+            } catch (SSLException ssle) {
+                Response resp = newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SSL PROTOCOL FAILURE: " + ssle.getMessage());
+                resp.send(this.outputStream);
+                safeClose(this.outputStream);
+            } catch (IOException ioe) {
+                Response resp = newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
+                resp.send(this.outputStream);
+                safeClose(this.outputStream);
+            } catch (ResponseException re) {
+                Response resp = newFixedLengthResponse(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage());
+                resp.send(this.outputStream);
+                safeClose(this.outputStream);
+            } finally {
+                safeClose(r);
+                this.tempFileManager.clear();
+            }
+        }
+
+        /**
+         * 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 + 1 < rlen) {
+
+                // RFC2616
+                if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && splitbyte + 3 < rlen && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') {
+                    return splitbyte + 4;
+                }
+
+                // tolerance
+                if (buf[splitbyte] == '\n' && buf[splitbyte + 1] == '\n') {
+                    return splitbyte + 2;
+                }
+                splitbyte++;
+            }
+            return 0;
+        }
+
+        /**
+         * Find the byte positions where multipart boundaries start. This reads
+         * a large block at a time and uses a temporary buffer to optimize
+         * (memory mapped) file access.
+         */
+        private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) {
+            int[] res = new int[0];
+            if (b.remaining() < boundary.length) {
+                return res;
+            }
+
+            int search_window_pos = 0;
+            byte[] search_window = new byte[4 * 1024 + boundary.length];
+
+            int first_fill = (b.remaining() < search_window.length) ? b.remaining() : search_window.length;
+            b.get(search_window, 0, first_fill);
+            int new_bytes = first_fill - boundary.length;
+
+            do {
+                // Search the search_window
+                for (int j = 0; j < new_bytes; j++) {
+                    for (int i = 0; i < boundary.length; i++) {
+                        if (search_window[j + i] != boundary[i])
+                            break;
+                        if (i == boundary.length - 1) {
+                            // Match found, add it to results
+                            int[] new_res = new int[res.length + 1];
+                            System.arraycopy(res, 0, new_res, 0, res.length);
+                            new_res[res.length] = search_window_pos + j;
+                            res = new_res;
+                        }
+                    }
+                }
+                search_window_pos += new_bytes;
+
+                // Copy the end of the buffer to the start
+                System.arraycopy(search_window, search_window.length - boundary.length, search_window, 0, boundary.length);
+
+                // Refill search_window
+                new_bytes = search_window.length - boundary.length;
+                new_bytes = (b.remaining() < new_bytes) ? b.remaining() : new_bytes;
+                b.get(search_window, boundary.length, new_bytes);
+            } while (new_bytes > 0);
+            return res;
+        }
+
+        @Override
+        public CookieHandler getCookies() {
+            return this.cookies;
+        }
+
+        @Override
+        public final Map<String, String> getHeaders() {
+            return this.headers;
+        }
+
+        @Override
+        public final InputStream getInputStream() {
+            return this.inputStream;
+        }
+
+        @Override
+        public final Method getMethod() {
+            return this.method;
+        }
+
+        /**
+         * @deprecated use {@link #getParameters()} instead.
+         */
+        @Override
+        @Deprecated
+        public final Map<String, String> getParms() {
+            Map<String, String> result = new HashMap<String, String>();
+            for (String key : this.parms.keySet()) {
+                result.put(key, this.parms.get(key).get(0));
+            }
+
+            return result;
+        }
+
+        @Override
+        public final Map<String, List<String>> getParameters() {
+            return this.parms;
+        }
+
+        @Override
+        public String getQueryParameterString() {
+            return this.queryParameterString;
+        }
+
+        private RandomAccessFile getTmpBucket() {
+            try {
+                TempFile tempFile = this.tempFileManager.createTempFile(null);
+                return new RandomAccessFile(tempFile.getName(), "rw");
+            } catch (Exception e) {
+                throw new Error(e); // we won't recover, so throw an error
+            }
+        }
+
+        @Override
+        public final String getUri() {
+            return this.uri;
+        }
+
+        /**
+         * Deduce body length in bytes. Either from "content-length" header or
+         * read bytes.
+         */
+        public long getBodySize() {
+            if (this.headers.containsKey("content-length")) {
+                return Long.parseLong(this.headers.get("content-length"));
+            } else if (this.splitbyte < this.rlen) {
+                return this.rlen - this.splitbyte;
+            }
+            return 0;
+        }
+
+        @Override
+        public void parseBody(Map<String, String> files) throws IOException, ResponseException {
+            RandomAccessFile randomAccessFile = null;
+            try {
+                long size = getBodySize();
+                ByteArrayOutputStream baos = null;
+                DataOutput requestDataOutput = null;
+
+                // Store the request in memory or a file, depending on size
+                if (size < MEMORY_STORE_LIMIT) {
+                    baos = new ByteArrayOutputStream();
+                    requestDataOutput = new DataOutputStream(baos);
+                } else {
+                    randomAccessFile = getTmpBucket();
+                    requestDataOutput = randomAccessFile;
+                }
+
+                // Read all the body and write it to request_data_output
+                byte[] buf = new byte[REQUEST_BUFFER_LEN];
+                while (this.rlen >= 0 && size > 0) {
+                    this.rlen = this.inputStream.read(buf, 0, (int) Math.min(size, REQUEST_BUFFER_LEN));
+                    size -= this.rlen;
+                    if (this.rlen > 0) {
+                        requestDataOutput.write(buf, 0, this.rlen);
+                    }
+                }
+
+                ByteBuffer fbuf = null;
+                if (baos != null) {
+                    fbuf = ByteBuffer.wrap(baos.toByteArray(), 0, baos.size());
+                } else {
+                    fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length());
+                    randomAccessFile.seek(0);
+                }
+
+                // If the method is POST, there may be parameters
+                // in data section, too, read it:
+                if (Method.POST.equals(this.method)) {
+                    ContentType contentType = new ContentType(this.headers.get("content-type"));
+                    if (contentType.isMultipart()) {
+                        String boundary = contentType.getBoundary();
+                        if (boundary == null) {
+                            throw new ResponseException(Response.Status.BAD_REQUEST,
+                                    "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html");
+                        }
+                        decodeMultipartFormData(contentType, fbuf, this.parms, files);
+                    } else {
+                        byte[] postBytes = new byte[fbuf.remaining()];
+                        fbuf.get(postBytes);
+                        String postLine = new String(postBytes, contentType.getEncoding()).trim();
+                        // Handle application/x-www-form-urlencoded
+                        if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType.getContentType())) {
+                            decodeParms(postLine, this.parms);
+                        } else if (postLine.length() != 0) {
+                            // Special case for raw POST data => create a
+                            // special files entry "postData" with raw content
+                            // data
+                            files.put("postData", postLine);
+                        }
+                    }
+                } else if (Method.PUT.equals(this.method)) {
+                    files.put("content", saveTmpFile(fbuf, 0, fbuf.limit(), null));
+                }
+            } finally {
+                safeClose(randomAccessFile);
+            }
+        }
+
+        /**
+         * 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 filename_hint) {
+            String path = "";
+            if (len > 0) {
+                FileOutputStream fileOutputStream = null;
+                try {
+                    TempFile tempFile = this.tempFileManager.createTempFile(filename_hint);
+                    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;
+        }
+
+        @Override
+        public String getRemoteIpAddress() {
+            return this.remoteIp;
+        }
+
+        @Override
+        public String getRemoteHostName() {
+            return this.remoteHostname;
+        }
+    }
+
+    /**
+     * Handles one session, i.e. parses the HTTP request and returns the
+     * response.
+     */
+    public interface IHTTPSession {
+
+        void execute() throws IOException;
+
+        CookieHandler getCookies();
+
+        Map<String, String> getHeaders();
+
+        InputStream getInputStream();
+
+        Method getMethod();
+
+        /**
+         * This method will only return the first value for a given parameter.
+         * You will want to use getParameters if you expect multiple values for
+         * a given key.
+         * 
+         * @deprecated use {@link #getParameters()} instead.
+         */
+        @Deprecated
+        Map<String, String> getParms();
+
+        Map<String, List<String>> getParameters();
+
+        String getQueryParameterString();
+
+        /**
+         * @return the path part of the URL.
+         */
+        String getUri();
+
+        /**
+         * Adds the files in the request body to the files map.
+         * 
+         * @param files
+         *            map to modify
+         */
+        void parseBody(Map<String, String> files) throws IOException, ResponseException;
+
+        /**
+         * Get the remote ip address of the requester.
+         * 
+         * @return the IP address.
+         */
+        String getRemoteIpAddress();
+
+        /**
+         * Get the remote hostname of the requester.
+         * 
+         * @return the hostname.
+         */
+        String getRemoteHostName();
+    }
+
+    /**
+     * 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,
+        TRACE,
+        CONNECT,
+        PATCH,
+        PROPFIND,
+        PROPPATCH,
+        MKCOL,
+        MOVE,
+        COPY,
+        LOCK,
+        UNLOCK;
+
+        static Method lookup(String method) {
+            if (method == null)
+                return null;
+
+            try {
+                return valueOf(method);
+            } catch (IllegalArgumentException e) {
+                // TODO: Log it?
+                return null;
+            }
+        }
+    }
+
+    /**
+     * HTTP response. Return one of these from serve().
+     */
+    public static class Response implements Closeable {
+
+        public interface IStatus {
+
+            String getDescription();
+
+            int getRequestStatus();
+        }
+
+        /**
+         * Some HTTP response status codes
+         */
+        public enum Status implements IStatus {
+            SWITCH_PROTOCOL(101, "Switching Protocols"),
+
+            OK(200, "OK"),
+            CREATED(201, "Created"),
+            ACCEPTED(202, "Accepted"),
+            NO_CONTENT(204, "No Content"),
+            PARTIAL_CONTENT(206, "Partial Content"),
+            MULTI_STATUS(207, "Multi-Status"),
+
+            REDIRECT(301, "Moved Permanently"),
+            /**
+             * Many user agents mishandle 302 in ways that violate the RFC1945
+             * spec (i.e., redirect a POST to a GET). 303 and 307 were added in
+             * RFC2616 to address this. You should prefer 303 and 307 unless the
+             * calling user agent does not support 303 and 307 functionality
+             */
+            @Deprecated
+            FOUND(302, "Found"),
+            REDIRECT_SEE_OTHER(303, "See Other"),
+            NOT_MODIFIED(304, "Not Modified"),
+            TEMPORARY_REDIRECT(307, "Temporary Redirect"),
+
+            BAD_REQUEST(400, "Bad Request"),
+            UNAUTHORIZED(401, "Unauthorized"),
+            FORBIDDEN(403, "Forbidden"),
+            NOT_FOUND(404, "Not Found"),
+            METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
+            NOT_ACCEPTABLE(406, "Not Acceptable"),
+            REQUEST_TIMEOUT(408, "Request Timeout"),
+            CONFLICT(409, "Conflict"),
+            GONE(410, "Gone"),
+            LENGTH_REQUIRED(411, "Length Required"),
+            PRECONDITION_FAILED(412, "Precondition Failed"),
+            PAYLOAD_TOO_LARGE(413, "Payload Too Large"),
+            UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"),
+            RANGE_NOT_SATISFIABLE(416, "Requested Range Not Satisfiable"),
+            EXPECTATION_FAILED(417, "Expectation Failed"),
+            TOO_MANY_REQUESTS(429, "Too Many Requests"),
+
+            INTERNAL_ERROR(500, "Internal Server Error"),
+            NOT_IMPLEMENTED(501, "Not Implemented"),
+            SERVICE_UNAVAILABLE(503, "Service Unavailable"),
+            UNSUPPORTED_HTTP_VERSION(505, "HTTP Version Not Supported");
+
+            private final int requestStatus;
+
+            private final String description;
+
+            Status(int requestStatus, String description) {
+                this.requestStatus = requestStatus;
+                this.description = description;
+            }
+
+            public static Status lookup(int requestStatus) {
+                for (Status status : Status.values()) {
+                    if (status.getRequestStatus() == requestStatus) {
+                        return status;
+                    }
+                }
+                return null;
+            }
+
+            @Override
+            public String getDescription() {
+                return "" + this.requestStatus + " " + this.description;
+            }
+
+            @Override
+            public int getRequestStatus() {
+                return this.requestStatus;
+            }
+
+        }
+
+        /**
+         * Output stream that will automatically send every write to the wrapped
+         * OutputStream according to chunked transfer:
+         * http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1
+         */
+        private static class ChunkedOutputStream extends FilterOutputStream {
+
+            public ChunkedOutputStream(OutputStream out) {
+                super(out);
+            }
+
+            @Override
+            public void write(int b) throws IOException {
+                byte[] data = {
+                    (byte) b
+                };
+                write(data, 0, 1);
+            }
+
+            @Override
+            public void write(byte[] b) throws IOException {
+                write(b, 0, b.length);
+            }
+
+            @Override
+            public void write(byte[] b, int off, int len) throws IOException {
+                if (len == 0)
+                    return;
+                out.write(String.format("%x\r\n", len).getBytes());
+                out.write(b, off, len);
+                out.write("\r\n".getBytes());
+            }
+
+            public void finish() throws IOException {
+                out.write("0\r\n\r\n".getBytes());
+            }
+
+        }
+
+        /**
+         * 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;
+
+        private long contentLength;
+
+        /**
+         * Headers for the HTTP response. Use addHeader() to add lines. the
+         * lowercase map is automatically kept up to date.
+         */
+        @SuppressWarnings("serial")
+        private final Map<String, String> header = new HashMap<String, String>() {
+
+            public String put(String key, String value) {
+                lowerCaseHeader.put(key == null ? key : key.toLowerCase(), value);
+                return super.put(key, value);
+            };
+        };
+
+        /**
+         * copy of the header map with all the keys lowercase for faster
+         * searching.
+         */
+        private final Map<String, String> lowerCaseHeader = new HashMap<String, String>();
+
+        /**
+         * The request method that spawned this response.
+         */
+        private Method requestMethod;
+
+        /**
+         * Use chunkedTransfer
+         */
+        private boolean chunkedTransfer;
+
+        private boolean encodeAsGzip;
+
+        private boolean keepAlive;
+
+        /**
+         * Creates a fixed length response if totalBytes>=0, otherwise chunked.
+         */
+        protected Response(IStatus status, String mimeType, InputStream data, long totalBytes) {
+            this.status = status;
+            this.mimeType = mimeType;
+            if (data == null) {
+                this.data = new ByteArrayInputStream(new byte[0]);
+                this.contentLength = 0L;
+            } else {
+                this.data = data;
+                this.contentLength = totalBytes;
+            }
+            this.chunkedTransfer = this.contentLength < 0;
+            keepAlive = true;
+        }
+
+        @Override
+        public void close() throws IOException {
+            if (this.data != null) {
+                this.data.close();
+            }
+        }
+
+        /**
+         * Adds given line to the header.
+         */
+        public void addHeader(String name, String value) {
+            this.header.put(name, value);
+        }
+
+        /**
+         * Indicate to close the connection after the Response has been sent.
+         * 
+         * @param close
+         *            {@code true} to hint connection closing, {@code false} to
+         *            let connection be closed by client.
+         */
+        public void closeConnection(boolean close) {
+            if (close)
+                this.header.put("connection", "close");
+            else
+                this.header.remove("connection");
+        }
+
+        /**
+         * @return {@code true} if connection is to be closed after this
+         *         Response has been sent.
+         */
+        public boolean isCloseConnection() {
+            return "close".equals(getHeader("connection"));
+        }
+
+        public InputStream getData() {
+            return this.data;
+        }
+
+        public String getHeader(String name) {
+            return this.lowerCaseHeader.get(name.toLowerCase());
+        }
+
+        public String getMimeType() {
+            return this.mimeType;
+        }
+
+        public Method getRequestMethod() {
+            return this.requestMethod;
+        }
+
+        public IStatus getStatus() {
+            return this.status;
+        }
+
+        public void setGzipEncoding(boolean encodeAsGzip) {
+            this.encodeAsGzip = encodeAsGzip;
+        }
+
+        public void setKeepAlive(boolean useKeepAlive) {
+            this.keepAlive = useKeepAlive;
+        }
+
+        /**
+         * Sends given response to the socket.
+         */
+        protected void send(OutputStream outputStream) {
+            SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
+            gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT"));
+
+            try {
+                if (this.status == null) {
+                    throw new Error("sendResponse(): Status can't be null.");
+                }
+                PrintWriter pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(outputStream, new ContentType(this.mimeType).getEncoding())), false);
+                pw.append("HTTP/1.1 ").append(this.status.getDescription()).append(" \r\n");
+                if (this.mimeType != null) {
+                    printHeader(pw, "Content-Type", this.mimeType);
+                }
+                if (getHeader("date") == null) {
+                    printHeader(pw, "Date", gmtFrmt.format(new Date()));
+                }
+                for (Entry<String, String> entry : this.header.entrySet()) {
+                    printHeader(pw, entry.getKey(), entry.getValue());
+                }
+                if (getHeader("connection") == null) {
+                    printHeader(pw, "Connection", (this.keepAlive ? "keep-alive" : "close"));
+                }
+                if (getHeader("content-length") != null) {
+                    encodeAsGzip = false;
+                }
+                if (encodeAsGzip) {
+                    printHeader(pw, "Content-Encoding", "gzip");
+                    setChunkedTransfer(true);
+                }
+                long pending = this.data != null ? this.contentLength : 0;
+                if (this.requestMethod != Method.HEAD && this.chunkedTransfer) {
+                    printHeader(pw, "Transfer-Encoding", "chunked");
+                } else if (!encodeAsGzip) {
+                    pending = sendContentLengthHeaderIfNotAlreadyPresent(pw, pending);
+                }
+                pw.append("\r\n");
+                pw.flush();
+                sendBodyWithCorrectTransferAndEncoding(outputStream, pending);
+                outputStream.flush();
+                safeClose(this.data);
+            } catch (IOException ioe) {
+                NanoHTTPD.LOG.log(Level.SEVERE, "Could not send response to the client", ioe);
+            }
+        }
+
+        @SuppressWarnings("static-method")
+        protected void printHeader(PrintWriter pw, String key, String value) {
+            pw.append(key).append(": ").append(value).append("\r\n");
+        }
+
+        protected long sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, long defaultSize) {
+            String contentLengthString = getHeader("content-length");
+            long size = defaultSize;
+            if (contentLengthString != null) {
+                try {
+                    size = Long.parseLong(contentLengthString);
+                } catch (NumberFormatException ex) {
+                    LOG.severe("content-length was no number " + contentLengthString);
+                }
+            }
+            pw.print("Content-Length: " + size + "\r\n");
+            return size;
+        }
+
+        private void sendBodyWithCorrectTransferAndEncoding(OutputStream outputStream, long pending) throws IOException {
+            if (this.requestMethod != Method.HEAD && this.chunkedTransfer) {
+                ChunkedOutputStream chunkedOutputStream = new ChunkedOutputStream(outputStream);
+                sendBodyWithCorrectEncoding(chunkedOutputStream, -1);
+                chunkedOutputStream.finish();
+            } else {
+                sendBodyWithCorrectEncoding(outputStream, pending);
+            }
+        }
+
+        private void sendBodyWithCorrectEncoding(OutputStream outputStream, long pending) throws IOException {
+            if (encodeAsGzip) {
+                GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream);
+                sendBody(gzipOutputStream, -1);
+                gzipOutputStream.finish();
+            } else {
+                sendBody(outputStream, pending);
+            }
+        }
+
+        /**
+         * Sends the body to the specified OutputStream. The pending parameter
+         * limits the maximum amounts of bytes sent unless it is -1, in which
+         * case everything is sent.
+         * 
+         * @param outputStream
+         *            the OutputStream to send data to
+         * @param pending
+         *            -1 to send everything, otherwise sets a max limit to the
+         *            number of bytes sent
+         * @throws IOException
+         *             if something goes wrong while sending the data.
+         */
+        private void sendBody(OutputStream outputStream, long pending) throws IOException {
+            long BUFFER_SIZE = 16 * 1024;
+            byte[] buff = new byte[(int) BUFFER_SIZE];
+            boolean sendEverything = pending == -1;
+            while (pending > 0 || sendEverything) {
+                long bytesToRead = sendEverything ? BUFFER_SIZE : Math.min(pending, BUFFER_SIZE);
+                int read = this.data.read(buff, 0, (int) bytesToRead);
+                if (read <= 0) {
+                    break;
+                }
+                outputStream.write(buff, 0, read);
+                if (!sendEverything) {
+                    pending -= read;
+                }
+            }
+        }
+
+        public void setChunkedTransfer(boolean chunkedTransfer) {
+            this.chunkedTransfer = chunkedTransfer;
+        }
+
+        public void setData(InputStream data) {
+            this.data = data;
+        }
+
+        public void setMimeType(String mimeType) {
+            this.mimeType = mimeType;
+        }
+
+        public void setRequestMethod(Method requestMethod) {
+            this.requestMethod = requestMethod;
+        }
+
+        public void setStatus(IStatus status) {
+            this.status = status;
+        }
+    }
+
+    public static 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 this.status;
+        }
+    }
+
+    /**
+     * The runnable that will be used for the main listening thread.
+     */
+    public class ServerRunnable implements Runnable {
+
+        private final int timeout;
+
+        private IOException bindException;
+
+        private boolean hasBinded = false;
+
+        public ServerRunnable(int timeout) {
+            this.timeout = timeout;
+        }
+
+        @Override
+        public void run() {
+            try {
+                myServerSocket.bind(hostname != null ? new InetSocketAddress(hostname, myPort) : new InetSocketAddress(myPort));
+                hasBinded = true;
+            } catch (IOException e) {
+                this.bindException = e;
+                return;
+            }
+            do {
+                try {
+                    final Socket finalAccept = NanoHTTPD.this.myServerSocket.accept();
+                    if (this.timeout > 0) {
+                        finalAccept.setSoTimeout(this.timeout);
+                    }
+                    final InputStream inputStream = finalAccept.getInputStream();
+                    NanoHTTPD.this.asyncRunner.exec(createClientHandler(finalAccept, inputStream));
+                } catch (IOException e) {
+                    NanoHTTPD.LOG.log(Level.FINE, "Communication with the client broken", e);
+                }
+            } while (!NanoHTTPD.this.myServerSocket.isClosed());
+        }
+    }
+
+    /**
+     * 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 {
+
+        public void delete() throws Exception;
+
+        public String getName();
+
+        public OutputStream open() throws Exception;
+    }
+
+    /**
+     * Temp file manager.
+     * <p/>
+     * <p>
+     * Temp file managers are created 1-to-1 with incoming requests, to create
+     * and cleanup temporary files created as a result of handling the request.
+     * </p>
+     */
+    public interface TempFileManager {
+
+        void clear();
+
+        public TempFile createTempFile(String filename_hint) throws Exception;
+    }
+
+    /**
+     * Factory to create temp file managers.
+     */
+    public interface TempFileManagerFactory {
+
+        public TempFileManager create();
+    }
+
+    /**
+     * Factory to create ServerSocketFactories.
+     */
+    public interface ServerSocketFactory {
+
+        public ServerSocket create() throws IOException;
+
+    }
+
+    /**
+     * Maximum time to wait on Socket.getInputStream().read() (in milliseconds)
+     * This is required as the Keep-Alive HTTP connections would otherwise block
+     * the socket reading thread forever (or as long the browser is open).
+     */
+    public static final int SOCKET_READ_TIMEOUT = 5000;
+
+    /**
+     * Common MIME type for dynamic content: plain text
+     */
+    public static final String MIME_PLAINTEXT = "text/plain";
+
+    /**
+     * Common MIME type for dynamic content: html
+     */
+    public static final String MIME_HTML = "text/html";
+
+    /**
+     * Pseudo-Parameter to use to store the actual query string in the
+     * parameters map for later re-processing.
+     */
+    private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING";
+
+    /**
+     * logger to log to.
+     */
+    private static final Logger LOG = Logger.getLogger(NanoHTTPD.class.getName());
+
+    /**
+     * Hashtable mapping (String)FILENAME_EXTENSION -> (String)MIME_TYPE
+     */
+    protected static Map<String, String> MIME_TYPES;
+
+    public static Map<String, String> mimeTypes() {
+        if (MIME_TYPES == null) {
+            MIME_TYPES = new HashMap<String, String>();
+            loadMimeTypes(MIME_TYPES, "META-INF/nanohttpd/default-mimetypes.properties");
+            loadMimeTypes(MIME_TYPES, "META-INF/nanohttpd/mimetypes.properties");
+            if (MIME_TYPES.isEmpty()) {
+                LOG.log(Level.WARNING, "no mime types found in the classpath! please provide mimetypes.properties");
+            }
+        }
+        return MIME_TYPES;
+    }
+
+    @SuppressWarnings({
+        "unchecked",
+        "rawtypes"
+    })
+    private static void loadMimeTypes(Map<String, String> result, String resourceName) {
+        try {
+            Enumeration<URL> resources = NanoHTTPD.class.getClassLoader().getResources(resourceName);
+            while (resources.hasMoreElements()) {
+                URL url = (URL) resources.nextElement();
+                Properties properties = new Properties();
+                InputStream stream = null;
+                try {
+                    stream = url.openStream();
+                    properties.load(stream);
+                } catch (IOException e) {
+                    LOG.log(Level.SEVERE, "could not load mimetypes from " + url, e);
+                } finally {
+                    safeClose(stream);
+                }
+                result.putAll((Map) properties);
+            }
+        } catch (IOException e) {
+            LOG.log(Level.INFO, "no mime types available at " + resourceName);
+        }
+    };
+
+    /**
+     * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and an
+     * array of loaded KeyManagers. These objects must properly
+     * loaded/initialized by the caller.
+     */
+    public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManager[] keyManagers) throws IOException {
+        SSLServerSocketFactory res = null;
+        try {
+            TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+            trustManagerFactory.init(loadedKeyStore);
+            SSLContext ctx = SSLContext.getInstance("TLS");
+            ctx.init(keyManagers, trustManagerFactory.getTrustManagers(), null);
+            res = ctx.getServerSocketFactory();
+        } catch (Exception e) {
+            throw new IOException(e.getMessage());
+        }
+        return res;
+    }
+
+    /**
+     * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and a
+     * loaded KeyManagerFactory. These objects must properly loaded/initialized
+     * by the caller.
+     */
+    public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManagerFactory loadedKeyFactory) throws IOException {
+        try {
+            return makeSSLSocketFactory(loadedKeyStore, loadedKeyFactory.getKeyManagers());
+        } catch (Exception e) {
+            throw new IOException(e.getMessage());
+        }
+    }
+
+    /**
+     * Creates an SSLSocketFactory for HTTPS. Pass a KeyStore resource with your
+     * certificate and passphrase
+     */
+    public static SSLServerSocketFactory makeSSLSocketFactory(String keyAndTrustStoreClasspathPath, char[] passphrase) throws IOException {
+        try {
+            KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
+            InputStream keystoreStream = NanoHTTPD.class.getResourceAsStream(keyAndTrustStoreClasspathPath);
+
+            if (keystoreStream == null) {
+                throw new IOException("Unable to load keystore from classpath: " + keyAndTrustStoreClasspathPath);
+            }
+
+            keystore.load(keystoreStream, passphrase);
+            KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+            keyManagerFactory.init(keystore, passphrase);
+            return makeSSLSocketFactory(keystore, keyManagerFactory);
+        } catch (Exception e) {
+            throw new IOException(e.getMessage());
+        }
+    }
+
+    /**
+     * Get MIME type from file name extension, if possible
+     * 
+     * @param uri
+     *            the string representing a file
+     * @return the connected mime/type
+     */
+    public static String getMimeTypeForFile(String uri) {
+        int dot = uri.lastIndexOf('.');
+        String mime = null;
+        if (dot >= 0) {
+            mime = mimeTypes().get(uri.substring(dot + 1).toLowerCase());
+        }
+        return mime == null ? "application/octet-stream" : mime;
+    }
+
+    private static final void safeClose(Object closeable) {
+        try {
+            if (closeable != null) {
+                if (closeable instanceof Closeable) {
+                    ((Closeable) closeable).close();
+                } else if (closeable instanceof Socket) {
+                    ((Socket) closeable).close();
+                } else if (closeable instanceof ServerSocket) {
+                    ((ServerSocket) closeable).close();
+                } else {
+                    throw new IllegalArgumentException("Unknown object to close");
+                }
+            }
+        } catch (IOException e) {
+            NanoHTTPD.LOG.log(Level.SEVERE, "Could not close", e);
+        }
+    }
+
+    private final String hostname;
+
+    private final int myPort;
+
+    private volatile ServerSocket myServerSocket;
+
+    private ServerSocketFactory serverSocketFactory = new DefaultServerSocketFactory();
+
+    private Thread myThread;
+
+    /**
+     * Pluggable strategy for asynchronously executing requests.
+     */
+    protected AsyncRunner asyncRunner;
+
+    /**
+     * Pluggable strategy for creating and cleaning up temporary files.
+     */
+    private TempFileManagerFactory tempFileManagerFactory;
+
+    /**
+     * Constructs an HTTP server on given port.
+     */
+    public NanoHTTPD(int port) {
+        this(null, port);
+    }
+
+    // -------------------------------------------------------------------------------
+    // //
+    //
+    // Threading Strategy.
+    //
+    // -------------------------------------------------------------------------------
+    // //
+
+    /**
+     * Constructs an HTTP server on given hostname and port.
+     */
+    public NanoHTTPD(String hostname, int port) {
+        this.hostname = hostname;
+        this.myPort = port;
+        setTempFileManagerFactory(new DefaultTempFileManagerFactory());
+        setAsyncRunner(new DefaultAsyncRunner());
+    }
+
+    /**
+     * Forcibly closes all connections that are open.
+     */
+    public synchronized void closeAllConnections() {
+        stop();
+    }
+
+    /**
+     * create a instance of the client handler, subclasses can return a subclass
+     * of the ClientHandler.
+     * 
+     * @param finalAccept
+     *            the socket the cleint is connected to
+     * @param inputStream
+     *            the input stream
+     * @return the client handler
+     */
+    protected ClientHandler createClientHandler(final Socket finalAccept, final InputStream inputStream) {
+        return new ClientHandler(inputStream, finalAccept);
+    }
+
+    /**
+     * Instantiate the server runnable, can be overwritten by subclasses to
+     * provide a subclass of the ServerRunnable.
+     * 
+     * @param timeout
+     *            the socet timeout to use.
+     * @return the server runnable.
+     */
+    protected ServerRunnable createServerRunnable(final int timeout) {
+        return new ServerRunnable(timeout);
+    }
+
+    /**
+     * 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&lt;String&gt;</code> (a list of the values supplied).
+     */
+    protected static Map<String, List<String>> decodeParameters(Map<String, String> parms) {
+        return decodeParameters(parms.get(NanoHTTPD.QUERY_STRING_PARAMETER));
+    }
+
+    // -------------------------------------------------------------------------------
+    // //
+
+    /**
+     * Decode parameters from a URL, handing the case where a single parameter
+     * name might have been supplied several times, by return lists of values.
+     * In general these lists will contain a single element.
+     * 
+     * @param queryString
+     *            a query string pulled from the URL.
+     * @return a map of <code>String</code> (parameter name) to
+     *         <code>List&lt;String&gt;</code> (a list of the values supplied).
+     */
+    protected static 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;
+    }
+
+    /**
+     * 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 static String decodePercent(String str) {
+        String decoded = null;
+        try {
+            decoded = URLDecoder.decode(str, "UTF8");
+        } catch (UnsupportedEncodingException ignored) {
+            NanoHTTPD.LOG.log(Level.WARNING, "Encoding not supported, ignored", ignored);
+        }
+        return decoded;
+    }
+
+    /**
+     * @return true if the gzip compression should be used if the client
+     *         accespts it. Default this option is on for text content and off
+     *         for everything. Override this for custom semantics.
+     */
+    @SuppressWarnings("static-method")
+    protected boolean useGzipWhenAccepted(Response r) {
+        return r.getMimeType() != null && (r.getMimeType().toLowerCase().contains("text/") || r.getMimeType().toLowerCase().contains("/json"));
+    }
+
+    public final int getListeningPort() {
+        return this.myServerSocket == null ? -1 : this.myServerSocket.getLocalPort();
+    }
+
+    public final boolean isAlive() {
+        return wasStarted() && !this.myServerSocket.isClosed() && this.myThread.isAlive();
+    }
+
+    public ServerSocketFactory getServerSocketFactory() {
+        return serverSocketFactory;
+    }
+
+    public void setServerSocketFactory(ServerSocketFactory serverSocketFactory) {
+        this.serverSocketFactory = serverSocketFactory;
+    }
+
+    public String getHostname() {
+        return hostname;
+    }
+
+    public TempFileManagerFactory getTempFileManagerFactory() {
+        return tempFileManagerFactory;
+    }
+
+    /**
+     * Call before start() to serve over HTTPS instead of HTTP
+     */
+    public void makeSecure(SSLServerSocketFactory sslServerSocketFactory, String[] sslProtocols) {
+        this.serverSocketFactory = new SecureServerSocketFactory(sslServerSocketFactory, sslProtocols);
+    }
+
+    /**
+     * Create a response with unknown length (using HTTP 1.1 chunking).
+     */
+    public static Response newChunkedResponse(IStatus status, String mimeType, InputStream data) {
+        return new Response(status, mimeType, data, -1);
+    }
+
+    /**
+     * Create a response with known length.
+     */
+    public static Response newFixedLengthResponse(IStatus status, String mimeType, InputStream data, long totalBytes) {
+        return new Response(status, mimeType, data, totalBytes);
+    }
+
+    /**
+     * Create a text response with known length.
+     */
+    public static Response newFixedLengthResponse(IStatus status, String mimeType, String txt) {
+        ContentType contentType = new ContentType(mimeType);
+        if (txt == null) {
+            return newFixedLengthResponse(status, mimeType, new ByteArrayInputStream(new byte[0]), 0);
+        } else {
+            byte[] bytes;
+            try {
+                CharsetEncoder newEncoder = Charset.forName(contentType.getEncoding()).newEncoder();
+                if (!newEncoder.canEncode(txt)) {
+                    contentType = contentType.tryUTF8();
+                }
+                bytes = txt.getBytes(contentType.getEncoding());
+            } catch (UnsupportedEncodingException e) {
+                NanoHTTPD.LOG.log(Level.SEVERE, "encoding problem, responding nothing", e);
+                bytes = new byte[0];
+            }
+            return newFixedLengthResponse(status, contentType.getContentTypeHeader(), new ByteArrayInputStream(bytes), bytes.length);
+        }
+    }
+
+    /**
+     * Create a text response with known length.
+     */
+    public static Response newFixedLengthResponse(String msg) {
+        return newFixedLengthResponse(Status.OK, NanoHTTPD.MIME_HTML, msg);
+    }
+
+    /**
+     * Override this to customize the server.
+     * <p/>
+     * <p/>
+     * (By default, this returns a 404 "Not Found" plain text error response.)
+     * 
+     * @param session
+     *            The HTTP session
+     * @return HTTP response, see class Response for details
+     */
+    public Response serve(IHTTPSession session) {
+        Map<String, String> files = new HashMap<String, String>();
+        Method method = session.getMethod();
+        if (Method.PUT.equals(method) || Method.POST.equals(method)) {
+            try {
+                session.parseBody(files);
+            } catch (IOException ioe) {
+                return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
+            } catch (ResponseException re) {
+                return newFixedLengthResponse(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage());
+            }
+        }
+
+        Map<String, String> parms = session.getParms();
+        parms.put(NanoHTTPD.QUERY_STRING_PARAMETER, session.getQueryParameterString());
+        return serve(session.getUri(), method, session.getHeaders(), parms, files);
+    }
+
+    /**
+     * Override this to customize the server.
+     * <p/>
+     * <p/>
+     * (By default, this returns a 404 "Not Found" plain text error response.)
+     * 
+     * @param uri
+     *            Percent-decoded URI without parameters, for example
+     *            "/index.cgi"
+     * @param method
+     *            "GET", "POST" etc.
+     * @param parms
+     *            Parsed, percent decoded parameters from URI and, in case of
+     *            POST, data.
+     * @param headers
+     *            Header entries, percent decoded
+     * @return HTTP response, see class Response for details
+     */
+    @Deprecated
+    public Response serve(String uri, Method method, Map<String, String> headers, Map<String, String> parms, Map<String, String> files) {
+        return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not Found");
+    }
+
+    /**
+     * Pluggable strategy for asynchronously executing requests.
+     * 
+     * @param asyncRunner
+     *            new strategy for handling threads.
+     */
+    public void setAsyncRunner(AsyncRunner asyncRunner) {
+        this.asyncRunner = asyncRunner;
+    }
+
+    /**
+     * Pluggable strategy for creating and cleaning up temporary files.
+     * 
+     * @param tempFileManagerFactory
+     *            new strategy for handling temp files.
+     */
+    public void setTempFileManagerFactory(TempFileManagerFactory tempFileManagerFactory) {
+        this.tempFileManagerFactory = tempFileManagerFactory;
+    }
+
+    /**
+     * Start the server.
+     * 
+     * @throws IOException
+     *             if the socket is in use.
+     */
+    public void start() throws IOException {
+        start(NanoHTTPD.SOCKET_READ_TIMEOUT);
+    }
+
+    /**
+     * Starts the server (in setDaemon(true) mode).
+     */
+    public void start(final int timeout) throws IOException {
+        start(timeout, true);
+    }
+
+    /**
+     * Start the server.
+     * 
+     * @param timeout
+     *            timeout to use for socket connections.
+     * @param daemon
+     *            start the thread daemon or not.
+     * @throws IOException
+     *             if the socket is in use.
+     */
+    public void start(final int timeout, boolean daemon) throws IOException {
+        this.myServerSocket = this.getServerSocketFactory().create();
+        this.myServerSocket.setReuseAddress(true);
+
+        ServerRunnable serverRunnable = createServerRunnable(timeout);
+        this.myThread = new Thread(serverRunnable);
+        this.myThread.setDaemon(daemon);
+        this.myThread.setName("NanoHttpd Main Listener");
+        this.myThread.start();
+        while (!serverRunnable.hasBinded && serverRunnable.bindException == null) {
+            try {
+                Thread.sleep(10L);
+            } catch (Throwable e) {
+                // on android this may not be allowed, that's why we
+                // catch throwable the wait should be very short because we are
+                // just waiting for the bind of the socket
+            }
+        }
+        if (serverRunnable.bindException != null) {
+            throw serverRunnable.bindException;
+        }
+    }
+
+    /**
+     * Stop the server.
+     */
+    public void stop() {
+        try {
+            safeClose(this.myServerSocket);
+            this.asyncRunner.closeAll();
+            if (this.myThread != null) {
+                this.myThread.join();
+            }
+        } catch (Exception e) {
+            NanoHTTPD.LOG.log(Level.SEVERE, "Could not stop all connections", e);
+        }
+    }
+
+    public final boolean wasStarted() {
+        return this.myServerSocket != null && this.myThread != null;
+    }
+}
index b3c1071908ed359c23205fe27902024101665b85..be1c654502f58bd91554873a0b241876837a33c0 100644 (file)
@@ -454,7 +454,10 @@ public class StringUtils {
         *            the input data
         * 
         * @return the hash
+        * 
+        * @deprecated please use {@link HashUtils}
         */
+       @Deprecated
        static public String getMd5Hash(String input) {
                try {
                        MessageDigest md = MessageDigest.getInstance("MD5");