From: Niki Roo Date: Mon, 11 May 2020 21:57:44 +0000 (+0200) Subject: Merge branch 'master' into subtree X-Git-Url: http://git.nikiroo.be/?p=nikiroo-utils.git;a=commitdiff_plain;h=5f3671e17febc5b7f6abbfc62c66c4045d47ec8d;hp=-c Merge branch 'master' into subtree --- 5f3671e17febc5b7f6abbfc62c66c4045d47ec8d diff --combined Instance.java index c3c086f,a2cb90a..a2cb90a --- a/Instance.java +++ b/Instance.java @@@ -15,6 -15,7 +15,7 @@@ import be.nikiroo.fanfix.library.BasicL 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 +68,21 @@@ public class Instance } /** - * Initialise the instance -- if already initialised, nothing will happen unless - * you pass TRUE to force. + * Initialise the instance -- if already initialised, nothing will happen + * unless you pass TRUE to force. *

- * 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. *

- * 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 force 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 + * force set to TRUE. + * + * @param force + * force the initialisation even if already initialised */ static public void init(boolean force) { synchronized (instancelock) { @@@ -95,7 -98,8 +98,8 @@@ *

* 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 +117,8 @@@ /** * Actually initialise the instance. *

- * 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 +160,24 @@@ 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 +195,8 @@@ /** * 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 +228,8 @@@ /** * 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 +339,8 @@@ * 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 +352,10 @@@ * 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 +376,13 @@@ */ 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 +402,8 @@@ */ 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 +421,8 @@@ } /** - * 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 +446,11 @@@ * {@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 +494,56 @@@ /** * 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 +553,12 @@@ /** * 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 +569,12 @@@ /** * 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 +585,10 @@@ /** * 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 +612,8 @@@ * properties. *

* 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 +660,8 @@@ 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 +676,8 @@@ /** * 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 +685,8 @@@ 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; } diff --combined Main.java index c0dd9e0,7be305a..7be305a --- a/Main.java +++ b/Main.java @@@ -19,6 -19,7 +19,7 @@@ import be.nikiroo.fanfix.library.CacheL 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 +30,6 @@@ import be.nikiroo.fanfix.supported.Supp 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 +79,7 @@@ public class Main *

  • --version: get the version of the program
  • *
  • --server: start the server mode (see config file for parameters)
  • *
  • --stop-server: stop the running server on this port if any
  • - *
  • --remote [key] [host] [port]: use a the given remote library
  • + *
  • --remote [key] [host] [port]: use the given remote library
  • * * * @param args @@@ -626,15 -626,8 +626,8 @@@ } 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 +1030,29 @@@ /** * 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 --combined bundles/Config.java index 3af83c1,c96ed22..c96ed22 --- a/bundles/Config.java +++ b/bundles/Config.java @@@ -58,7 -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 +84,19 @@@ @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 +105,12 @@@ @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 --combined data/JsonIO.java index 0000000,db81db6..db81db6 mode 000000,100644..100644 --- a/data/JsonIO.java +++ b/data/JsonIO.java @@@ -1,0 -1,164 +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 toListString(JSONArray array) { + if (array != null) { + List values = new ArrayList(); + 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; + } + } diff --combined library/MetaResultList.java index 0903740,8b8a167..8b8a167 --- a/library/MetaResultList.java +++ b/library/MetaResultList.java @@@ -224,7 -224,7 +224,7 @@@ public class MetaResultList if (sources == null && authors == null && tags == null) { return metas; } - + // allow "sources/" hierarchy if (sources != null) { List folders = new ArrayList(); diff --combined library/RemoteLibrary.java index a4f00ce,9fa8c66..9fa8c66 --- a/library/RemoteLibrary.java +++ b/library/RemoteLibrary.java @@@ -21,6 -21,8 +21,8 @@@ import be.nikiroo.utils.serial.server.C * 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. + *

    + * This remote library uses a custom fanfix:// protocol. * * @author niki */ @@@ -35,8 -37,8 +37,8 @@@ public class RemoteLibrary extends Basi } @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 +115,26 @@@ 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 +188,8 @@@ @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 +240,8 @@@ 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 +362,7 @@@ // 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 +382,8 @@@ 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 +532,8 @@@ 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)) { diff --combined library/RemoteLibraryServer.java index f92c37e,c150a01..c150a01 --- a/library/RemoteLibraryServer.java +++ b/library/RemoteLibraryServer.java @@@ -70,16 -70,16 +70,16 @@@ public class RemoteLibraryServer extend * 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 --combined library/WebLibrary.java index 0000000,369eb23..369eb23 mode 000000,100644..100644 --- a/library/WebLibrary.java +++ b/library/WebLibrary.java @@@ -1,0 -1,236 +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. + *

    + * 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. + *

    + * Note that the key is structured: + * xxx(|yyy|wl)(|rw) + *

    + * Note that anything before the first pipe (|) character is + * considered to be the encryption key, anything after that character is + * called the subkey (including the other pipe characters and flags!). + *

    + * 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. + *

      + *
    • xxx: the encryption key used to communicate with the + * server
    • + *
    • yyy: the secondary key
    • + *
    • rw: flag to allow read and write access if it is not the + * default on this server
    • + *
    • wl: flag to allow access to all the stories (bypassing the + * whitelist if it exists)
    • + *
    + *

    + * Some examples: + *

      + *
    • my_key: normal connection, will take the default server + * options
    • + *
    • my_key|agzyzz|wl: will ask to bypass the white list (if it + * exists)
    • + *
    • my_key|agzyzz|rw: will ask read-write access (if the default + * is read-only)
    • + *
    • my_key|agzyzz|wl|rw: will ask both read-write access and white + * list bypass
    • + *
    + * + * @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 getMetas(Progress pg) throws IOException { + List metas = new ArrayList(); + 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 post = new HashMap(); + post.put("login", subkey); + post.put("password", key); + + return Instance.getInstance().getCache().openNoCache(url, null, post, + null, null); + } + } diff --combined library/WebLibraryServer.java index 0000000,e0096fc..e0096fc mode 000000,100644..100644 --- a/library/WebLibraryServer.java +++ b/library/WebLibraryServer.java @@@ -1,0 -1,1092 +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 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(); + } + subkeys = new ArrayList(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 storyCache = new HashMap(); + private LinkedList storyCacheOrder = new LinkedList(); + 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 cookies = new HashMap(); + for (String cookie : session.getCookies()) { + cookies.put(cookie, session.getCookies().read(cookie)); + } + + List whitelist = Instance.getInstance().getConfig() + .getList(Config.SERVER_WHITELIST); + if (whitelist == null) { + whitelist = new ArrayList(); + } + + LoginResult login = null; + Map 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). + *

    + * Can only be called once. + *

    + * 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 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 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 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("

    Bad login or password
    "); + } else if (login.isBadToken()) { + builder.append("
    Your session timed out
    "); + } + + if (uri.equals("/logout")) { + uri = "/"; + } + + builder.append( + "\n"); + + appendPostHtml(builder); + + return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN, + NanoHTTPD.MIME_HTML, builder.toString()); + } + + protected Response getList(String uri, List whitelist) + throws IOException { + if (uri.equals("/list/luids")) { + BasicLibrary lib = Instance.getInstance().getLibrary(); + List metas = lib.getList().filter(whitelist, null, null); + List jsons = new ArrayList(); + 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 cookies, + List 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 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("
    \n"); + builder.append("\n"); + + // TODO: javascript in realtime, using visible=false + hide [submit] + builder.append("
    \n"); + builder.append("\tFilter: \n"); + builder.append( + "\t\n"); + builder.append("\t\n"); + builder.append( + "\t\n"); + builder.append("
    \n"); + builder.append("
    \n"); + + builder.append("\t
    "); + 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("\n"); + } + builder.append("
    "); + + 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 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 cookies, String uri, + List 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 = "
    \n" + + new TextOutput(false).convert(chap, true) + + "
    \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("" // + + "" // + + "" + + "", // + next, // + zoomStyle, // + getStoryUrl(luid, chapter, paragraph)); + } else { + content = para.getContent(); + } + + } + + builder.append(String.format("" // + + "\n" // + + "%s", // + first, // + previous, // + next, // + last, // + content // + )); + + builder.append("
    \n"); + builder.append( + " BACK\n"); + + if (paragraph > 0) { + builder.append(String.format("" // + + "\tREAL\n"// + + "\tWIDTH\n"// + + "\tHEIGHT\n"// + + "
    \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 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 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 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("\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("\n"); + } + } diff --combined library/web/WebLibraryServerIndex.java index 0000000,15c371b..15c371b mode 000000,100644..100644 --- a/library/web/WebLibraryServerIndex.java +++ b/library/web/WebLibraryServerIndex.java @@@ -1,0 -1,4 +1,4 @@@ + package be.nikiroo.fanfix.library.web; + + public class WebLibraryServerIndex { + } diff --combined library/web/actual_size-32x32.png index 0000000,0d356b7..0d356b7 mode 000000,100644..100644 Binary files differ diff --combined library/web/actual_size-64x64.png index 0000000,474afda..474afda mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_double_left-32x32.png index 0000000,e163b60..e163b60 mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_double_left-64x64.png index 0000000,e66dd93..e66dd93 mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_double_right-32x32.png index 0000000,297dcab..297dcab mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_double_right-64x64.png index 0000000,52226f6..52226f6 mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_left-32x32.png index 0000000,af83425..af83425 mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_left-64x64.png index 0000000,44cba9c..44cba9c mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_right-32x32.png index 0000000,2540b62..2540b62 mode 000000,100644..100644 Binary files differ diff --combined library/web/arrow_right-64x64.png index 0000000,ab960b3..ab960b3 mode 000000,100644..100644 Binary files differ diff --combined library/web/avicon.ico index 0000000,e69de29..e69de29 mode 000000,100644..100644 --- a/library/web/avicon.ico +++ b/library/web/avicon.ico diff --combined library/web/back-32x32.png index 0000000,46c19dc..46c19dc mode 000000,100644..100644 Binary files differ diff --combined library/web/back-64x64.png index 0000000,d58d004..d58d004 mode 000000,100644..100644 Binary files differ diff --combined library/web/favicon.ico index 0000000,feedaf0..feedaf0 mode 000000,100644..100644 Binary files differ diff --combined library/web/fit_to_height-32x32.png index 0000000,727dec2..727dec2 mode 000000,100644..100644 Binary files differ diff --combined library/web/fit_to_height-64x64.png index 0000000,cec7da4..cec7da4 mode 000000,100644..100644 Binary files differ diff --combined library/web/fit_to_width-32x32.png index 0000000,ee90843..ee90843 mode 000000,100644..100644 Binary files differ diff --combined library/web/fit_to_width-64x64.png index 0000000,7b897d2..7b897d2 mode 000000,100644..100644 Binary files differ diff --combined library/web/icon_alternative.png index 0000000,4ab0957..4ab0957 mode 000000,100644..100644 Binary files differ diff --combined library/web/icon_default.png index 0000000,983b344..983b344 mode 000000,100644..100644 Binary files differ diff --combined library/web/icon_magic_book.png index 0000000,1798dd3..1798dd3 mode 000000,100644..100644 Binary files differ diff --combined library/web/icon_pony_book.png index 0000000,fb6fe0d..fb6fe0d mode 000000,100644..100644 Binary files differ diff --combined library/web/icon_pony_library.png index 0000000,a56a4d2..a56a4d2 mode 000000,100644..100644 Binary files differ diff --combined library/web/index.post.html index 0000000,d4e0905..d4e0905 mode 000000,100644..100644 --- a/library/web/index.post.html +++ b/library/web/index.post.html @@@ -1,0 -1,2 +1,2 @@@ + + diff --combined library/web/index.pre.html index 0000000,18c1508..18c1508 mode 000000,100644..100644 --- a/library/web/index.pre.html +++ b/library/web/index.pre.html @@@ -1,0 -1,48 +1,48 @@@ + + + + + + + Fanfix + + + + +
    diff --combined library/web/search-32x32.png index 0000000,92b716d..92b716d mode 000000,100644..100644 Binary files differ diff --combined library/web/search-64x64.png index 0000000,93dbf6d..93dbf6d mode 000000,100644..100644 Binary files differ diff --combined library/web/style.css index 0000000,c520d78..c520d78 mode 000000,100644..100644 --- a/library/web/style.css +++ b/library/web/style.css @@@ -1,0 -1,196 +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 --combined library/web/unknown-32x32.png index 0000000,d2315d1..d2315d1 mode 000000,100644..100644 Binary files differ diff --combined library/web/unknown-64x64.png index 0000000,261889d..261889d mode 000000,100644..100644 Binary files differ diff --combined reader/TextOutput.java index 0000000,60b3a7f..60b3a7f mode 000000,100644..100644 --- a/reader/TextOutput.java +++ b/reader/TextOutput.java @@@ -1,0 -1,148 +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 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(""); + } + + if (chapterName) { + builder.append("

    "); + builder.append("Chapter "); + builder.append(chap.getNumber()); + if (chap.getName() != null + && !chap.getName().trim().isEmpty()) { + builder.append(": "); + builder.append(chap.getName()); + } + builder.append("

    "); + } + + builder.append("
    "); + } + + @Override + protected void writeChapterFooter(Chapter chap) throws IOException { + if (paraInQuote) { + builder.append("
    "); + } + paraInQuote = false; + + builder.append("
    "); + + if (standalone) { + builder.append(""); + } + } + + @Override + protected void writeParagraph(Paragraph para) throws IOException { + if ((para.getType() == ParagraphType.QUOTE) == !paraInQuote) { + paraInQuote = !paraInQuote; + if (paraInQuote) { + builder.append("
    "); + builder.append("
    "); + } else { + builder.append("
    "); + builder.append("
    "); + } + } + + switch (para.getType()) { + case NORMAL: + builder.append("    "); + builder.append(decorateText(para.getContent())); + builder.append("
    "); + break; + case BLANK: + builder.append("
    "); + break; + case BREAK: + // Used to be 7777DD + builder.append("

    "); + builder.append("* * *"); + builder.append("

    "); + builder.append("
    "); + break; + case QUOTE: + builder.append("
    "); + builder.append("    "); + builder.append("— "); + builder.append(decorateText(para.getContent())); + builder.append("
    "); + + break; + case IMAGE: + } + } + + @Override + protected String enbold(String word) { + // Used to be COLOR='#7777DD' + return "" + word + ""; + } + + @Override + protected String italize(String word) { + return "" + word + ""; + } + }; + } + + /** + * 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(); + } + }