Merge branch 'master' into subtree
authorNiki Roo <niki@nikiroo.be>
Mon, 11 May 2020 21:57:44 +0000 (23:57 +0200)
committerNiki Roo <niki@nikiroo.be>
Mon, 11 May 2020 21:57:44 +0000 (23:57 +0200)
42 files changed:
Instance.java
Main.java
VERSION
bundles/Config.java
data/JsonIO.java [new file with mode: 0644]
library/MetaResultList.java
library/RemoteLibrary.java
library/RemoteLibraryServer.java
library/WebLibrary.java [new file with mode: 0644]
library/WebLibraryServer.java [new file with mode: 0644]
library/web/WebLibraryServerIndex.java [new file with mode: 0644]
library/web/actual_size-32x32.png [new file with mode: 0644]
library/web/actual_size-64x64.png [new file with mode: 0644]
library/web/arrow_double_left-32x32.png [new file with mode: 0644]
library/web/arrow_double_left-64x64.png [new file with mode: 0644]
library/web/arrow_double_right-32x32.png [new file with mode: 0644]
library/web/arrow_double_right-64x64.png [new file with mode: 0644]
library/web/arrow_left-32x32.png [new file with mode: 0644]
library/web/arrow_left-64x64.png [new file with mode: 0644]
library/web/arrow_right-32x32.png [new file with mode: 0644]
library/web/arrow_right-64x64.png [new file with mode: 0644]
library/web/avicon.ico [new file with mode: 0644]
library/web/back-32x32.png [new file with mode: 0644]
library/web/back-64x64.png [new file with mode: 0644]
library/web/favicon.ico [new file with mode: 0644]
library/web/fit_to_height-32x32.png [new file with mode: 0644]
library/web/fit_to_height-64x64.png [new file with mode: 0644]
library/web/fit_to_width-32x32.png [new file with mode: 0644]
library/web/fit_to_width-64x64.png [new file with mode: 0644]
library/web/icon_alternative.png [new file with mode: 0644]
library/web/icon_default.png [new file with mode: 0644]
library/web/icon_magic_book.png [new file with mode: 0644]
library/web/icon_pony_book.png [new file with mode: 0644]
library/web/icon_pony_library.png [new file with mode: 0644]
library/web/index.post.html [new file with mode: 0644]
library/web/index.pre.html [new file with mode: 0644]
library/web/search-32x32.png [new file with mode: 0644]
library/web/search-64x64.png [new file with mode: 0644]
library/web/style.css [new file with mode: 0644]
library/web/unknown-32x32.png [new file with mode: 0644]
library/web/unknown-64x64.png [new file with mode: 0644]
reader/TextOutput.java [new file with mode: 0644]

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)
--- a/Main.java
+++ b/Main.java
@@ -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);
+               }
        }
 
        /**
diff --git a/VERSION b/VERSION
index ef538c2810938c03ced86f0380977b308a55b37b..47ae1f7823d77b8b3c60cdd0e303f0cb5dd24286 100644 (file)
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.1.2
+3.1.2-dev
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/data/JsonIO.java b/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/library/WebLibrary.java b/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/library/WebLibraryServer.java b/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/library/web/WebLibraryServerIndex.java b/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/library/web/actual_size-32x32.png b/library/web/actual_size-32x32.png
new file mode 100644 (file)
index 0000000..0d356b7
Binary files /dev/null and b/library/web/actual_size-32x32.png differ
diff --git a/library/web/actual_size-64x64.png b/library/web/actual_size-64x64.png
new file mode 100644 (file)
index 0000000..474afda
Binary files /dev/null and b/library/web/actual_size-64x64.png differ
diff --git a/library/web/arrow_double_left-32x32.png b/library/web/arrow_double_left-32x32.png
new file mode 100644 (file)
index 0000000..e163b60
Binary files /dev/null and b/library/web/arrow_double_left-32x32.png differ
diff --git a/library/web/arrow_double_left-64x64.png b/library/web/arrow_double_left-64x64.png
new file mode 100644 (file)
index 0000000..e66dd93
Binary files /dev/null and b/library/web/arrow_double_left-64x64.png differ
diff --git a/library/web/arrow_double_right-32x32.png b/library/web/arrow_double_right-32x32.png
new file mode 100644 (file)
index 0000000..297dcab
Binary files /dev/null and b/library/web/arrow_double_right-32x32.png differ
diff --git a/library/web/arrow_double_right-64x64.png b/library/web/arrow_double_right-64x64.png
new file mode 100644 (file)
index 0000000..52226f6
Binary files /dev/null and b/library/web/arrow_double_right-64x64.png differ
diff --git a/library/web/arrow_left-32x32.png b/library/web/arrow_left-32x32.png
new file mode 100644 (file)
index 0000000..af83425
Binary files /dev/null and b/library/web/arrow_left-32x32.png differ
diff --git a/library/web/arrow_left-64x64.png b/library/web/arrow_left-64x64.png
new file mode 100644 (file)
index 0000000..44cba9c
Binary files /dev/null and b/library/web/arrow_left-64x64.png differ
diff --git a/library/web/arrow_right-32x32.png b/library/web/arrow_right-32x32.png
new file mode 100644 (file)
index 0000000..2540b62
Binary files /dev/null and b/library/web/arrow_right-32x32.png differ
diff --git a/library/web/arrow_right-64x64.png b/library/web/arrow_right-64x64.png
new file mode 100644 (file)
index 0000000..ab960b3
Binary files /dev/null and b/library/web/arrow_right-64x64.png differ
diff --git a/library/web/avicon.ico b/library/web/avicon.ico
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/library/web/back-32x32.png b/library/web/back-32x32.png
new file mode 100644 (file)
index 0000000..46c19dc
Binary files /dev/null and b/library/web/back-32x32.png differ
diff --git a/library/web/back-64x64.png b/library/web/back-64x64.png
new file mode 100644 (file)
index 0000000..d58d004
Binary files /dev/null and b/library/web/back-64x64.png differ
diff --git a/library/web/favicon.ico b/library/web/favicon.ico
new file mode 100644 (file)
index 0000000..feedaf0
Binary files /dev/null and b/library/web/favicon.ico differ
diff --git a/library/web/fit_to_height-32x32.png b/library/web/fit_to_height-32x32.png
new file mode 100644 (file)
index 0000000..727dec2
Binary files /dev/null and b/library/web/fit_to_height-32x32.png differ
diff --git a/library/web/fit_to_height-64x64.png b/library/web/fit_to_height-64x64.png
new file mode 100644 (file)
index 0000000..cec7da4
Binary files /dev/null and b/library/web/fit_to_height-64x64.png differ
diff --git a/library/web/fit_to_width-32x32.png b/library/web/fit_to_width-32x32.png
new file mode 100644 (file)
index 0000000..ee90843
Binary files /dev/null and b/library/web/fit_to_width-32x32.png differ
diff --git a/library/web/fit_to_width-64x64.png b/library/web/fit_to_width-64x64.png
new file mode 100644 (file)
index 0000000..7b897d2
Binary files /dev/null and b/library/web/fit_to_width-64x64.png differ
diff --git a/library/web/icon_alternative.png b/library/web/icon_alternative.png
new file mode 100644 (file)
index 0000000..4ab0957
Binary files /dev/null and b/library/web/icon_alternative.png differ
diff --git a/library/web/icon_default.png b/library/web/icon_default.png
new file mode 100644 (file)
index 0000000..983b344
Binary files /dev/null and b/library/web/icon_default.png differ
diff --git a/library/web/icon_magic_book.png b/library/web/icon_magic_book.png
new file mode 100644 (file)
index 0000000..1798dd3
Binary files /dev/null and b/library/web/icon_magic_book.png differ
diff --git a/library/web/icon_pony_book.png b/library/web/icon_pony_book.png
new file mode 100644 (file)
index 0000000..fb6fe0d
Binary files /dev/null and b/library/web/icon_pony_book.png differ
diff --git a/library/web/icon_pony_library.png b/library/web/icon_pony_library.png
new file mode 100644 (file)
index 0000000..a56a4d2
Binary files /dev/null and b/library/web/icon_pony_library.png differ
diff --git a/library/web/index.post.html b/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/library/web/index.pre.html b/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/library/web/search-32x32.png b/library/web/search-32x32.png
new file mode 100644 (file)
index 0000000..92b716d
Binary files /dev/null and b/library/web/search-32x32.png differ
diff --git a/library/web/search-64x64.png b/library/web/search-64x64.png
new file mode 100644 (file)
index 0000000..93dbf6d
Binary files /dev/null and b/library/web/search-64x64.png differ
diff --git a/library/web/style.css b/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/library/web/unknown-32x32.png b/library/web/unknown-32x32.png
new file mode 100644 (file)
index 0000000..d2315d1
Binary files /dev/null and b/library/web/unknown-32x32.png differ
diff --git a/library/web/unknown-64x64.png b/library/web/unknown-64x64.png
new file mode 100644 (file)
index 0000000..261889d
Binary files /dev/null and b/library/web/unknown-64x64.png differ
diff --git a/reader/TextOutput.java b/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();
+       }
+}