debug
[fanfix.git] / src / be / nikiroo / fanfix / library / WebLibraryServer.java
CommitLineData
f433d153
NR
1package be.nikiroo.fanfix.library;
2
3import java.io.ByteArrayInputStream;
4import java.io.File;
5import java.io.FileInputStream;
6import java.io.IOException;
7import java.io.InputStream;
8import java.security.KeyStore;
9import java.util.ArrayList;
10import java.util.HashMap;
11import java.util.LinkedList;
12import java.util.List;
13import java.util.Map;
14
15import javax.net.ssl.KeyManagerFactory;
16import javax.net.ssl.SSLServerSocketFactory;
17
18import org.json.JSONArray;
19import org.json.JSONObject;
20
21import be.nikiroo.fanfix.Instance;
22import be.nikiroo.fanfix.bundles.Config;
23import be.nikiroo.fanfix.bundles.UiConfig;
24import be.nikiroo.fanfix.data.Chapter;
25import be.nikiroo.fanfix.data.JsonIO;
26import be.nikiroo.fanfix.data.MetaData;
27import be.nikiroo.fanfix.data.Paragraph;
28import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
29import be.nikiroo.fanfix.data.Story;
30import be.nikiroo.fanfix.library.web.WebLibraryServerIndex;
31import be.nikiroo.fanfix.reader.TextOutput;
32import be.nikiroo.utils.CookieUtils;
33import be.nikiroo.utils.IOUtils;
34import be.nikiroo.utils.Image;
35import be.nikiroo.utils.NanoHTTPD;
36import be.nikiroo.utils.NanoHTTPD.IHTTPSession;
37import be.nikiroo.utils.NanoHTTPD.Response;
38import be.nikiroo.utils.NanoHTTPD.Response.Status;
39import be.nikiroo.utils.TraceHandler;
40import be.nikiroo.utils.Version;
41
42public class WebLibraryServer implements Runnable {
43 static private String VIEWER_URL_BASE = "/view/story/";
44 static private String VIEWER_URL = VIEWER_URL_BASE + "{luid}/{chap}/{para}";
45 static private String STORY_URL_BASE = "/story/";
46 static private String STORY_URL = STORY_URL_BASE + "{luid}/{chap}/{para}";
47 static private String STORY_URL_COVER = STORY_URL_BASE + "{luid}/cover";
48 static private String LIST_URL = "/list/";
49
50 private class LoginResult {
51 private boolean success;
52 private boolean rw;
53 private boolean wl;
d11fb35b 54 private boolean bl;
f433d153
NR
55 private String wookie;
56 private String token;
57 private boolean badLogin;
58 private boolean badToken;
59
60 public LoginResult(String who, String key, String subkey,
d11fb35b 61 boolean success, boolean rw, boolean wl, boolean bl) {
f433d153
NR
62 this.success = success;
63 this.rw = rw;
64 this.wl = wl;
d11fb35b 65 this.bl = bl;
f433d153
NR
66 this.wookie = CookieUtils.generateCookie(who + key, 0);
67
68 String opts = "";
69 if (rw)
70 opts += "|rw";
71 if (!wl)
72 opts += "|wl";
d11fb35b
NR
73 if (!bl)
74 opts += "|bl";
aac272ee 75 opts += "|";
f433d153
NR
76
77 this.token = wookie + "~"
78 + CookieUtils.generateCookie(wookie + subkey + opts, 0)
79 + "~" + opts;
80 this.badLogin = !success;
81 }
82
83 public LoginResult(String token, String who, String key,
84 List<String> subkeys) {
85
86 if (token != null) {
87 String hashes[] = token.split("~");
88 if (hashes.length >= 2) {
89 String wookie = hashes[0];
90 String rehashed = hashes[1];
91 String opts = hashes.length > 2 ? hashes[2] : "";
92
93 if (CookieUtils.validateCookie(who + key, wookie)) {
94 if (subkeys == null) {
95 subkeys = new ArrayList<String>();
96 }
97 subkeys = new ArrayList<String>(subkeys);
98 subkeys.add("");
99
100 for (String subkey : subkeys) {
599b05c7
NR
101 if (CookieUtils.validateCookie(
102 wookie + subkey + opts, rehashed)) {
f433d153
NR
103 this.wookie = wookie;
104 this.token = token;
105 this.success = true;
106
aac272ee
NR
107 this.rw = opts.contains("|rw|");
108 this.wl = !opts.contains("|wl|");
109 this.bl = !opts.contains("|bl|");
f433d153
NR
110 }
111 }
112 }
113 }
114
115 this.badToken = !success;
116 }
117
118 // No token -> no bad token
119 }
120
121 public boolean isSuccess() {
122 return success;
123 }
124
125 public boolean isRw() {
126 return rw;
127 }
128
129 public boolean isWl() {
130 return wl;
131 }
132
d11fb35b
NR
133 public boolean isBl() {
134 return bl;
135 }
136
f433d153
NR
137 public String getToken() {
138 return token;
139 }
140
141 public boolean isBadLogin() {
142 return badLogin;
143 }
144
145 public boolean isBadToken() {
146 return badToken;
147 }
148 }
149
150 private NanoHTTPD server;
151 private Map<String, Story> storyCache = new HashMap<String, Story>();
152 private LinkedList<String> storyCacheOrder = new LinkedList<String>();
153 private long storyCacheSize = 0;
154 private long maxStoryCacheSize;
155 private TraceHandler tracer = new TraceHandler();
156
d11fb35b
NR
157 private List<String> whitelist;
158 private List<String> blacklist;
159
f433d153
NR
160 public WebLibraryServer(boolean secure) throws IOException {
161 Integer port = Instance.getInstance().getConfig()
162 .getInteger(Config.SERVER_PORT);
163 if (port == null) {
599b05c7
NR
164 throw new IOException(
165 "Cannot start web server: port not specified");
f433d153
NR
166 }
167
168 int cacheMb = Instance.getInstance().getConfig()
169 .getInteger(Config.SERVER_MAX_CACHE_MB, 100);
170 maxStoryCacheSize = cacheMb * 1024 * 1024;
171
172 setTraceHandler(Instance.getInstance().getTraceHandler());
173
d11fb35b
NR
174 whitelist = Instance.getInstance().getConfig()
175 .getList(Config.SERVER_WHITELIST, new ArrayList<String>());
176 blacklist = Instance.getInstance().getConfig()
177 .getList(Config.SERVER_BLACKLIST, new ArrayList<String>());
178
f433d153
NR
179 SSLServerSocketFactory ssf = null;
180 if (secure) {
181 String keystorePath = Instance.getInstance().getConfig()
182 .getString(Config.SERVER_SSL_KEYSTORE, "");
183 String keystorePass = Instance.getInstance().getConfig()
184 .getString(Config.SERVER_SSL_KEYSTORE_PASS);
185
186 if (secure && keystorePath.isEmpty()) {
187 throw new IOException(
188 "Cannot start a secure web server: no keystore.jks file povided");
189 }
190
191 if (!keystorePath.isEmpty()) {
192 File keystoreFile = new File(keystorePath);
193 try {
599b05c7
NR
194 KeyStore keystore = KeyStore
195 .getInstance(KeyStore.getDefaultType());
f433d153
NR
196 InputStream keystoreStream = new FileInputStream(
197 keystoreFile);
198 try {
199 keystore.load(keystoreStream,
200 keystorePass.toCharArray());
201 KeyManagerFactory keyManagerFactory = KeyManagerFactory
202 .getInstance(KeyManagerFactory
203 .getDefaultAlgorithm());
204 keyManagerFactory.init(keystore,
205 keystorePass.toCharArray());
206 ssf = NanoHTTPD.makeSSLSocketFactory(keystore,
207 keyManagerFactory);
208 } finally {
209 keystoreStream.close();
210 }
211 } catch (Exception e) {
212 throw new IOException(e.getMessage());
213 }
214 }
215 }
216
217 server = new NanoHTTPD(port) {
218 @Override
219 public Response serve(final IHTTPSession session) {
220 super.serve(session);
221
222 String query = session.getQueryParameterString(); // a=a%20b&dd=2
223 Method method = session.getMethod(); // GET, POST..
224 String uri = session.getUri(); // /home.html
225
226 // need them in real time (not just those sent by the UA)
227 Map<String, String> cookies = new HashMap<String, String>();
228 for (String cookie : session.getCookies()) {
229 cookies.put(cookie, session.getCookies().read(cookie));
230 }
231
f433d153
NR
232 LoginResult login = null;
233 Map<String, String> params = session.getParms();
234 String who = session.getRemoteHostName()
235 + session.getRemoteIpAddress();
236 if (params.get("login") != null) {
237 login = login(who, params.get("password"),
d11fb35b 238 params.get("login"));
f433d153
NR
239 } else {
240 String token = cookies.get("token");
d11fb35b 241 login = login(who, token);
f433d153
NR
242 }
243
244 if (login.isSuccess()) {
245 // refresh token
599b05c7
NR
246 session.getCookies().set(new Cookie("token",
247 login.getToken(), "30; path=/"));
f433d153
NR
248
249 // set options
250 String optionName = params.get("optionName");
251 if (optionName != null && !optionName.isEmpty()) {
599b05c7 252 String optionNo = params.get("optionNo");
f433d153 253 String optionValue = params.get("optionValue");
599b05c7
NR
254 if (optionNo != null || optionValue == null
255 || optionValue.isEmpty()) {
f433d153
NR
256 session.getCookies().delete(optionName);
257 cookies.remove(optionName);
258 } else {
599b05c7
NR
259 session.getCookies().set(new Cookie(optionName,
260 optionValue, "; path=/"));
f433d153
NR
261 cookies.put(optionName, optionValue);
262 }
263 }
264 }
265
266 Response rep = null;
267 if (!login.isSuccess() && (uri.equals("/") //
268 || uri.startsWith(STORY_URL_BASE) //
269 || uri.startsWith(VIEWER_URL_BASE) //
599b05c7 270 || uri.startsWith(LIST_URL))) {
f433d153
NR
271 rep = loginPage(login, uri);
272 }
273
274 if (rep == null) {
275 try {
276 if (uri.equals("/")) {
d11fb35b 277 rep = root(session, cookies, login);
f433d153 278 } else if (uri.startsWith(LIST_URL)) {
d11fb35b 279 rep = getList(uri, login);
f433d153 280 } else if (uri.startsWith(STORY_URL_BASE)) {
d11fb35b 281 rep = getStoryPart(uri, login);
f433d153 282 } else if (uri.startsWith(VIEWER_URL_BASE)) {
d11fb35b 283 rep = getViewer(cookies, uri, login);
f433d153
NR
284 } else if (uri.equals("/logout")) {
285 session.getCookies().delete("token");
286 cookies.remove("token");
287 rep = loginPage(login, uri);
288 } else {
289 if (uri.startsWith("/"))
290 uri = uri.substring(1);
291 InputStream in = IOUtils.openResource(
292 WebLibraryServerIndex.class, uri);
293 if (in != null) {
294 String mimeType = MIME_PLAINTEXT;
295 if (uri.endsWith(".css")) {
296 mimeType = "text/css";
297 } else if (uri.endsWith(".html")) {
298 mimeType = "text/html";
299 } else if (uri.endsWith(".js")) {
300 mimeType = "text/javascript";
301 }
302 rep = newChunkedResponse(Status.OK, mimeType,
303 in);
304 } else {
305 getTraceHandler().trace("404: " + uri);
306 }
307 }
308
309 if (rep == null) {
310 rep = newFixedLengthResponse(Status.NOT_FOUND,
311 NanoHTTPD.MIME_PLAINTEXT, "Not Found");
312 }
313 } catch (Exception e) {
599b05c7
NR
314 Instance.getInstance().getTraceHandler().error(
315 new IOException("Cannot process web request",
316 e));
f433d153
NR
317 rep = newFixedLengthResponse(Status.INTERNAL_ERROR,
318 NanoHTTPD.MIME_PLAINTEXT, "An error occured");
319 }
320 }
321
322 return rep;
f433d153
NR
323 }
324 };
325
326 if (ssf != null) {
327 getTraceHandler().trace("Install SSL on the web server...");
328 server.makeSecure(ssf, null);
329 getTraceHandler().trace("Done.");
330 }
331 }
332
333 @Override
334 public void run() {
335 try {
336 server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
337 } catch (IOException e) {
338 tracer.error(new IOException("Cannot start the web server", e));
339 }
340 }
341
342 /**
343 * Start the server (listen on the network for new connections).
344 * <p>
345 * Can only be called once.
346 * <p>
347 * This call is asynchronous, and will just start a new {@link Thread} on
348 * itself (see {@link WebLibraryServer#run()}).
349 */
350 public void start() {
351 new Thread(this).start();
352 }
353
354 /**
355 * The traces handler for this {@link WebLibraryServer}.
356 *
357 * @return the traces handler
358 */
359 public TraceHandler getTraceHandler() {
360 return tracer;
361 }
362
363 /**
364 * The traces handler for this {@link WebLibraryServer}.
365 *
366 * @param tracer
367 * the new traces handler
368 */
369 public void setTraceHandler(TraceHandler tracer) {
370 if (tracer == null) {
371 tracer = new TraceHandler(false, false, false);
372 }
373
374 this.tracer = tracer;
375 }
376
d11fb35b
NR
377 private LoginResult login(String who, String token) {
378 List<String> subkeys = Instance.getInstance().getConfig().getList(
379 Config.SERVER_ALLOWED_SUBKEYS, new ArrayList<String>());
f433d153 380 String realKey = Instance.getInstance().getConfig()
d11fb35b
NR
381 .getString(Config.SERVER_KEY, "");
382
f433d153
NR
383 return new LoginResult(token, who, realKey, subkeys);
384 }
385
386 // allow rw/wl
d11fb35b 387 private LoginResult login(String who, String key, String subkey) {
f433d153 388 String realKey = Instance.getInstance().getConfig()
d11fb35b 389 .getString(Config.SERVER_KEY, "");
f433d153
NR
390
391 // I don't like NULLs...
f433d153
NR
392 key = key == null ? "" : key;
393 subkey = subkey == null ? "" : subkey;
394
395 if (!realKey.equals(key)) {
d11fb35b
NR
396 return new LoginResult(null, null, null, false, false, false,
397 false);
f433d153
NR
398 }
399
d11fb35b 400 // defaults are true (as previous versions without the feature)
f433d153
NR
401 boolean rw = true;
402 boolean wl = true;
d11fb35b 403 boolean bl = true;
f433d153 404
599b05c7
NR
405 rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
406 rw);
f433d153
NR
407 if (!subkey.isEmpty()) {
408 List<String> allowed = Instance.getInstance().getConfig()
409 .getList(Config.SERVER_ALLOWED_SUBKEYS);
410 if (allowed != null && allowed.contains(subkey)) {
411 if ((subkey + "|").contains("|rw|")) {
412 rw = true;
413 }
414 if ((subkey + "|").contains("|wl|")) {
415 wl = false; // |wl| = bypass whitelist
416 }
d11fb35b
NR
417 if ((subkey + "|").contains("|bl|")) {
418 bl = false; // |bl| = bypass blacklist
419 }
f433d153 420 } else {
d11fb35b
NR
421 return new LoginResult(null, null, null, false, false, false,
422 false);
f433d153
NR
423 }
424 }
425
d11fb35b 426 return new LoginResult(who, key, subkey, true, rw, wl, bl);
f433d153
NR
427 }
428
429 private Response loginPage(LoginResult login, String uri) {
430 StringBuilder builder = new StringBuilder();
431
432 appendPreHtml(builder, true);
433
434 if (login.isBadLogin()) {
435 builder.append("<div class='error'>Bad login or password</div>");
436 } else if (login.isBadToken()) {
437 builder.append("<div class='error'>Your session timed out</div>");
438 }
439
440 if (uri.equals("/logout")) {
441 uri = "/";
442 }
443
599b05c7
NR
444 builder.append(
445 "<form method='POST' action='" + uri + "' class='login'>\n");
446 builder.append(
447 "<p>You must be logged into the system to see the stories.</p>");
f433d153
NR
448 builder.append("\t<input type='text' name='login' />\n");
449 builder.append("\t<input type='password' name='password' />\n");
450 builder.append("\t<input type='submit' value='Login' />\n");
451 builder.append("</form>\n");
452
453 appendPostHtml(builder);
454
455 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
456 NanoHTTPD.MIME_HTML, builder.toString());
457 }
458
d11fb35b 459 protected Response getList(String uri, LoginResult login)
f433d153
NR
460 throws IOException {
461 if (uri.equals("/list/luids")) {
f433d153 462 List<JSONObject> jsons = new ArrayList<JSONObject>();
d11fb35b 463 for (MetaData meta : metas(login)) {
f433d153
NR
464 jsons.add(JsonIO.toJson(meta));
465 }
466
467 return newInputStreamResponse("application/json",
599b05c7
NR
468 new ByteArrayInputStream(
469 new JSONArray(jsons).toString().getBytes()));
f433d153
NR
470 }
471
472 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
473 NanoHTTPD.MIME_PLAINTEXT, null);
474 }
475
476 private Response root(IHTTPSession session, Map<String, String> cookies,
d11fb35b 477 LoginResult login) throws IOException {
f433d153 478 BasicLibrary lib = Instance.getInstance().getLibrary();
d11fb35b 479 MetaResultList result = new MetaResultList(metas(login));
f433d153
NR
480 StringBuilder builder = new StringBuilder();
481
482 appendPreHtml(builder, true);
483
4bd7d202 484 Map<String, String> params = session.getParms();
599b05c7 485
f433d153 486 String filter = cookies.get("filter");
599b05c7 487 if (params.get("optionNo") != null)
4bd7d202 488 filter = null;
f433d153
NR
489 if (filter == null) {
490 filter = "";
491 }
492
599b05c7
NR
493 String browser = params.get("browser") == null ? ""
494 : params.get("browser");
495 String browser2 = params.get("browser2") == null ? ""
496 : params.get("browser2");
497 String browser3 = params.get("browser3") == null ? ""
498 : params.get("browser3");
f433d153
NR
499
500 String filterSource = null;
501 String filterAuthor = null;
502 String filterTag = null;
503
504 // TODO: javascript in realtime, using visible=false + hide [submit]
505
506 builder.append("<form class='browser'>\n");
507 builder.append("<div class='breadcrumbs'>\n");
508
509 builder.append("\t<select name='browser'>");
510 appendOption(builder, 2, "", "", browser);
511 appendOption(builder, 2, "Sources", "sources", browser);
512 appendOption(builder, 2, "Authors", "authors", browser);
513 appendOption(builder, 2, "Tags", "tags", browser);
514 builder.append("\t</select>\n");
515
516 if (!browser.isEmpty()) {
517 builder.append("\t<select name='browser2'>");
518 if (browser.equals("sources")) {
519 filterSource = browser2.isEmpty() ? filterSource : browser2;
520 // TODO: if 1 group -> no group
521 appendOption(builder, 2, "", "", browser2);
522 Map<String, List<String>> sources = result.getSourcesGrouped();
523 for (String source : sources.keySet()) {
524 appendOption(builder, 2, source, source, browser2);
525 }
526 } else if (browser.equals("authors")) {
527 filterAuthor = browser2.isEmpty() ? filterAuthor : browser2;
528 // TODO: if 1 group -> no group
529 appendOption(builder, 2, "", "", browser2);
530 Map<String, List<String>> authors = result.getAuthorsGrouped();
531 for (String author : authors.keySet()) {
532 appendOption(builder, 2, author, author, browser2);
533 }
534 } else if (browser.equals("tags")) {
535 filterTag = browser2.isEmpty() ? filterTag : browser2;
536 appendOption(builder, 2, "", "", browser2);
537 for (String tag : result.getTags()) {
538 appendOption(builder, 2, tag, tag, browser2);
539 }
540 }
541 builder.append("\t</select>\n");
542 }
543
544 if (!browser2.isEmpty()) {
545 if (browser.equals("sources")) {
546 filterSource = browser3.isEmpty() ? filterSource : browser3;
547 Map<String, List<String>> sourcesGrouped = result
548 .getSourcesGrouped();
549 List<String> sources = sourcesGrouped.get(browser2);
550 if (sources != null && !sources.isEmpty()) {
551 // TODO: single empty value
552 builder.append("\t<select name='browser3'>");
553 appendOption(builder, 2, "", "", browser3);
554 for (String source : sources) {
555 appendOption(builder, 2, source, source, browser3);
556 }
557 builder.append("\t</select>\n");
558 }
559 } else if (browser.equals("authors")) {
560 filterAuthor = browser3.isEmpty() ? filterAuthor : browser3;
561 Map<String, List<String>> authorsGrouped = result
562 .getAuthorsGrouped();
563 List<String> authors = authorsGrouped.get(browser2);
564 if (authors != null && !authors.isEmpty()) {
565 // TODO: single empty value
566 builder.append("\t<select name='browser3'>");
567 appendOption(builder, 2, "", "", browser3);
568 for (String author : authors) {
569 appendOption(builder, 2, author, author, browser3);
570 }
571 builder.append("\t</select>\n");
572 }
573 }
574 }
575
576 builder.append("\t<input type='submit' value='Select'/>\n");
577 builder.append("</div>\n");
578
579 // TODO: javascript in realtime, using visible=false + hide [submit]
580 builder.append("<div class='filter'>\n");
599b05c7
NR
581 builder.append("\t<span class='label'>Filter: </span>\n");
582 builder.append(
583 "\t<input name='optionName' type='hidden' value='filter' />\n");
f433d153
NR
584 builder.append("\t<input name='optionValue' type='text' value='"
585 + filter + "' place-holder='...' />\n");
599b05c7
NR
586 builder.append("\t<input name='optionNo' type='submit' value='x' />");
587 builder.append(
588 "\t<input name='submit' type='submit' value='Filter' />\n");
f433d153
NR
589 builder.append("</div>\n");
590 builder.append("</form>\n");
591
592 builder.append("\t<div class='books'>");
593 for (MetaData meta : result.getMetas()) {
599b05c7
NR
594 if (!filter.isEmpty() && !meta.getTitle().toLowerCase()
595 .contains(filter.toLowerCase())) {
f433d153
NR
596 continue;
597 }
598
599 // TODO Sub sources
599b05c7
NR
600 if (filterSource != null
601 && !filterSource.equals(meta.getSource())) {
f433d153
NR
602 continue;
603 }
604
605 // TODO: sub authors
599b05c7
NR
606 if (filterAuthor != null
607 && !filterAuthor.equals(meta.getAuthor())) {
f433d153
NR
608 continue;
609 }
610
611 if (filterTag != null && !meta.getTags().contains(filterTag)) {
612 continue;
613 }
614
615 builder.append("<div class='book_line'>");
616 builder.append("<a href='");
4c3426fb 617 builder.append(getViewUrl(meta.getLuid(), null, null));
f433d153
NR
618 builder.append("'");
619 builder.append(" class='link'>");
620
621 if (lib.isCached(meta.getLuid())) {
622 // â—‰ = &#9673;
599b05c7
NR
623 builder.append(
624 "<span class='cache_icon cached'>&#9673;</span>");
f433d153
NR
625 } else {
626 // â—‹ = &#9675;
599b05c7
NR
627 builder.append(
628 "<span class='cache_icon uncached'>&#9675;</span>");
f433d153
NR
629 }
630 builder.append("<span class='luid'>");
631 builder.append(meta.getLuid());
632 builder.append("</span>");
633 builder.append("<span class='title'>");
634 builder.append(meta.getTitle());
635 builder.append("</span>");
636 builder.append("<span class='author'>");
637 if (meta.getAuthor() != null && !meta.getAuthor().isEmpty()) {
638 builder.append("(").append(meta.getAuthor()).append(")");
639 }
640 builder.append("</span>");
641 builder.append("</a></div>\n");
642 }
643 builder.append("</div>");
644
645 appendPostHtml(builder);
646 return NanoHTTPD.newFixedLengthResponse(builder.toString());
647 }
648
649 // /story/luid/chapter/para <-- text/image
650 // /story/luid/cover <-- image
651 // /story/luid/metadata <-- json
c5103223 652 // /story/luid/json <-- json, whole chapter (no images)
d11fb35b 653 private Response getStoryPart(String uri, LoginResult login) {
f433d153
NR
654 String[] cover = uri.split("/");
655 int off = 2;
656
657 if (cover.length < off + 2) {
658 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
659 NanoHTTPD.MIME_PLAINTEXT, null);
660 }
661
662 String luid = cover[off + 0];
663 String chapterStr = cover[off + 1];
664 String imageStr = cover.length < off + 3 ? null : cover[off + 2];
665
666 // 1-based (0 = desc)
667 int chapter = 0;
668 if (chapterStr != null && !"cover".equals(chapterStr)
599b05c7
NR
669 && !"metadata".equals(chapterStr)
670 && !"json".equals(chapterStr)) {
f433d153
NR
671 try {
672 chapter = Integer.parseInt(chapterStr);
673 if (chapter < 0) {
674 throw new NumberFormatException();
675 }
676 } catch (NumberFormatException e) {
677 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
678 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
679 }
680 }
681
682 // 1-based
683 int paragraph = 1;
684 if (imageStr != null) {
685 try {
686 paragraph = Integer.parseInt(imageStr);
687 if (paragraph < 0) {
688 throw new NumberFormatException();
689 }
690 } catch (NumberFormatException e) {
691 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
692 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
693 }
694 }
695
696 String mimeType = NanoHTTPD.MIME_PLAINTEXT;
697 InputStream in = null;
698 try {
699 if ("cover".equals(chapterStr)) {
d11fb35b 700 Image img = getCover(luid, login);
f433d153
NR
701 if (img != null) {
702 in = img.newInputStream();
703 }
3f468ac7
NR
704 // TODO: get correct image type
705 mimeType = "image/png";
f433d153 706 } else if ("metadata".equals(chapterStr)) {
d11fb35b 707 MetaData meta = meta(luid, login);
f433d153
NR
708 JSONObject json = JsonIO.toJson(meta);
709 mimeType = "application/json";
710 in = new ByteArrayInputStream(json.toString().getBytes());
3fbc084c 711 } else if ("json".equals(chapterStr)) {
d11fb35b 712 Story story = story(luid, login);
c5103223
NR
713 JSONObject json = JsonIO.toJson(story);
714 mimeType = "application/json";
715 in = new ByteArrayInputStream(json.toString().getBytes());
f433d153 716 } else {
d11fb35b 717 Story story = story(luid, login);
f433d153
NR
718 if (story != null) {
719 if (chapter == 0) {
720 StringBuilder builder = new StringBuilder();
721 for (Paragraph p : story.getMeta().getResume()) {
722 if (builder.length() == 0) {
723 builder.append("\n");
724 }
725 builder.append(p.getContent());
726 }
727
599b05c7
NR
728 in = new ByteArrayInputStream(
729 builder.toString().getBytes("utf-8"));
f433d153
NR
730 } else {
731 Paragraph para = story.getChapters().get(chapter - 1)
732 .getParagraphs().get(paragraph - 1);
733 Image img = para.getContentImage();
734 if (para.getType() == ParagraphType.IMAGE) {
735 // TODO: get correct image type
736 mimeType = "image/png";
737 in = img.newInputStream();
738 } else {
599b05c7
NR
739 in = new ByteArrayInputStream(
740 para.getContent().getBytes("utf-8"));
f433d153
NR
741 }
742 }
743 }
744 }
745 } catch (IndexOutOfBoundsException e) {
746 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
747 NanoHTTPD.MIME_PLAINTEXT,
748 "Chapter or paragraph does not exist");
749 } catch (IOException e) {
750 Instance.getInstance().getTraceHandler()
751 .error(new IOException("Cannot get image: " + uri, e));
752 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
753 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
754 }
755
756 return newInputStreamResponse(mimeType, in);
757 }
758
759 private Response getViewer(Map<String, String> cookies, String uri,
d11fb35b 760 LoginResult login) {
f433d153
NR
761 String[] cover = uri.split("/");
762 int off = 2;
763
764 if (cover.length < off + 2) {
765 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
766 NanoHTTPD.MIME_PLAINTEXT, null);
767 }
768
769 String type = cover[off + 0];
770 String luid = cover[off + 1];
771 String chapterStr = cover.length < off + 3 ? null : cover[off + 2];
772 String paragraphStr = cover.length < off + 4 ? null : cover[off + 3];
773
774 // 1-based (0 = desc)
4c3426fb 775 int chapter = 0;
f433d153
NR
776 if (chapterStr != null) {
777 try {
778 chapter = Integer.parseInt(chapterStr);
779 if (chapter < 0) {
780 throw new NumberFormatException();
781 }
782 } catch (NumberFormatException e) {
783 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
784 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
785 }
786 }
787
788 // 1-based
789 int paragraph = 0;
790 if (paragraphStr != null) {
791 try {
792 paragraph = Integer.parseInt(paragraphStr);
793 if (paragraph <= 0) {
794 throw new NumberFormatException();
795 }
796 } catch (NumberFormatException e) {
797 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
798 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
799 }
800 }
801
802 try {
d11fb35b 803 Story story = story(luid, login);
f433d153
NR
804 if (story == null) {
805 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
806 NanoHTTPD.MIME_PLAINTEXT, "Story not found");
807 }
808
809 StringBuilder builder = new StringBuilder();
810 appendPreHtml(builder, false);
811
599b05c7 812 // For images documents, always go to the images if not chap 0 desc
db9146bc 813 if (story.getMeta().isImageDocument()) {
599b05c7 814 if (chapter > 0 && paragraph <= 0)
db9146bc
NR
815 paragraph = 1;
816 }
f433d153 817
db9146bc
NR
818 Chapter chap = null;
819 if (chapter <= 0) {
820 chap = story.getMeta().getResume();
821 } else {
f433d153
NR
822 try {
823 chap = story.getChapters().get(chapter - 1);
824 } catch (IndexOutOfBoundsException e) {
825 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
826 NanoHTTPD.MIME_PLAINTEXT, "Chapter not found");
827 }
db9146bc 828 }
f433d153 829
db9146bc
NR
830 String first, previous, next, last;
831
832 StringBuilder content = new StringBuilder();
833
834 String disabledLeft = "";
835 String disabledRight = "";
836 String disabledZoomReal = "";
837 String disabledZoomWidth = "";
838 String disabledZoomHeight = "";
839
840 if (paragraph <= 0) {
599b05c7
NR
841 first = getViewUrl(luid, 0, null);
842 previous = getViewUrl(luid, (Math.max(chapter - 1, 0)), null);
db9146bc
NR
843 next = getViewUrl(luid,
844 (Math.min(chapter + 1, story.getChapters().size())),
845 null);
846 last = getViewUrl(luid, story.getChapters().size(), null);
847
db9146bc
NR
848 StringBuilder desc = new StringBuilder();
849
850 if (chapter <= 0) {
6b675cd4
NR
851 desc.append("<h1 class='title'>");
852 desc.append(story.getMeta().getTitle());
853 desc.append("</h1>\n");
db9146bc 854 desc.append("<div class='desc'>\n");
4c3426fb 855 desc.append("\t<a href='" + next + "' class='cover'>\n");
db9146bc 856 desc.append("\t\t<img src='/story/" + luid + "/cover'/>\n");
4c3426fb 857 desc.append("\t</a>\n");
599b05c7
NR
858 desc.append("\t<table class='details'>\n");
859 Map<String, String> details = BasicLibrary
860 .getMetaDesc(story.getMeta());
861 for (String key : details.keySet()) {
862 appendTableRow(desc, 2, key, details.get(key));
863 }
db9146bc
NR
864 desc.append("\t</table>\n");
865 desc.append("</div>\n");
866 desc.append("<h1 class='title'>Description</h1>\n");
f433d153
NR
867 }
868
db9146bc
NR
869 content.append("<div class='viewer text'>\n");
870 content.append(desc);
599b05c7
NR
871 String description = new TextOutput(false).convert(chap,
872 chapter > 0);
6b675cd4
NR
873 content.append(chap.getParagraphs().size() <= 0
874 ? "No content provided."
875 : description);
db9146bc 876 content.append("</div>\n");
3fbc084c 877
599b05c7 878 if (chapter <= 0)
db9146bc
NR
879 disabledLeft = " disabled='disbaled'";
880 if (chapter >= story.getChapters().size())
881 disabledRight = " disabled='disbaled'";
882 } else {
883 first = getViewUrl(luid, chapter, 1);
884 previous = getViewUrl(luid, chapter,
885 (Math.max(paragraph - 1, 1)));
886 next = getViewUrl(luid, chapter,
887 (Math.min(paragraph + 1, chap.getParagraphs().size())));
888 last = getViewUrl(luid, chapter, chap.getParagraphs().size());
889
890 if (paragraph <= 1)
891 disabledLeft = " disabled='disbaled'";
892 if (paragraph >= chap.getParagraphs().size())
893 disabledRight = " disabled='disbaled'";
894
599b05c7
NR
895 // First -> previous *chapter*
896 if (chapter > 0)
897 disabledLeft = "";
898 first = getViewUrl(luid, (Math.max(chapter - 1, 0)), null);
899 if (paragraph <= 1) {
900 previous = first;
901 }
902
db9146bc
NR
903 Paragraph para = null;
904 try {
905 para = chap.getParagraphs().get(paragraph - 1);
906 } catch (IndexOutOfBoundsException e) {
907 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
599b05c7
NR
908 NanoHTTPD.MIME_PLAINTEXT,
909 "Paragraph " + paragraph + " not found");
db9146bc 910 }
f433d153 911
db9146bc
NR
912 if (para.getType() == ParagraphType.IMAGE) {
913 String zoomStyle = "max-width: 100%;";
914 disabledZoomWidth = " disabled='disabled'";
915 String zoomOption = cookies.get("zoom");
916 if (zoomOption != null && !zoomOption.isEmpty()) {
917 if (zoomOption.equals("real")) {
918 zoomStyle = "";
919 disabledZoomWidth = "";
920 disabledZoomReal = " disabled='disabled'";
921 } else if (zoomOption.equals("width")) {
922 zoomStyle = "max-width: 100%;";
923 } else if (zoomOption.equals("height")) {
924 // see height of navbar + optionbar
925 zoomStyle = "max-height: calc(100% - 128px);";
926 disabledZoomWidth = "";
927 disabledZoomHeight = " disabled='disabled'";
f433d153 928 }
f433d153
NR
929 }
930
842afa6c 931 String javascript = "document.getElementById(\"previous\").click(); return false;";
db9146bc 932 content.append(String.format("" //
842afa6c 933 + "<a class='viewer link' oncontextmenu='%s' href='%s'>"
db9146bc
NR
934 + "<img class='viewer img' style='%s' src='%s'/>"
935 + "</a>", //
842afa6c 936 javascript, //
db9146bc
NR
937 next, //
938 zoomStyle, //
939 getStoryUrl(luid, chapter, paragraph)));
940 } else {
599b05c7
NR
941 content.append(String.format("" //
942 + "<div class='viewer text'>%s</div>", //
943 para.getContent()));
f433d153 944 }
db9146bc 945 }
f433d153 946
db9146bc
NR
947 builder.append(String.format("" //
948 + "<div class='bar navbar'>\n" //
949 + "\t<a%s class='button first' href='%s'>&lt;&lt;</a>\n"//
842afa6c 950 + "\t<a%s id='previous' class='button previous' href='%s'>&lt;</a>\n" //
6b89e45c
NR
951 + "\t<div class='gotobox itemsbox'>\n" //
952 + "\t\t<div class='button goto'>%d</div>\n" //
953 + "\t\t<div class='items goto'>\n", //
db9146bc
NR
954 disabledLeft, first, //
955 disabledLeft, previous, //
6b89e45c
NR
956 paragraph > 0 ? paragraph : chapter //
957 ));
958
959 // List of chap/para links
960
4c3426fb 961 appendItemA(builder, 3, getViewUrl(luid, 0, null), "Description",
6b89e45c 962 paragraph == 0 && chapter == 0);
6b89e45c 963 if (paragraph > 0) {
6b89e45c 964 for (int i = 1; i <= chap.getParagraphs().size(); i++) {
4c3426fb
NR
965 appendItemA(builder, 3, getViewUrl(luid, chapter, i),
966 "Image " + i, paragraph == i);
6b89e45c
NR
967 }
968 } else {
969 int i = 1;
970 for (Chapter c : story.getChapters()) {
971 String chapName = "Chapter " + c.getNumber();
972 if (c.getName() != null && !c.getName().isEmpty()) {
973 chapName += ": " + c.getName();
974 }
975
4c3426fb
NR
976 appendItemA(builder, 3, getViewUrl(luid, i, null), chapName,
977 chapter == i);
6b89e45c
NR
978
979 i++;
980 }
981 }
982
983 builder.append(String.format("" //
984 + "\t\t</div>\n" //
985 + "\t</div>\n" //
986 + "\t<a%s class='button next' href='%s'>&gt;</a>\n" //
987 + "\t<a%s class='button last' href='%s'>&gt;&gt;</a>\n"//
988 + "</div>\n", //
db9146bc
NR
989 disabledRight, next, //
990 disabledRight, last //
599b05c7 991 ));
db9146bc
NR
992
993 builder.append(content);
994
995 builder.append("<div class='bar optionbar ");
996 if (paragraph > 0) {
997 builder.append("s4");
998 } else {
999 builder.append("s1");
1000 }
1001 builder.append("'>\n");
1002 builder.append(" <a class='button back' href='/'>BACK</a>\n");
1003
1004 if (paragraph > 0) {
599b05c7
NR
1005 builder.append(String.format("" //
1006 + "\t<a%s class='button zoomreal' href='%s'>REAL</a>\n"//
1007 + "\t<a%s class='button zoomwidth' href='%s'>WIDTH</a>\n"//
1008 + "\t<a%s class='button zoomheight' href='%s'>HEIGHT</a>\n"//
1009 + "</div>\n", //
1010 disabledZoomReal,
1011 uri + "?optionName=zoom&optionValue=real", //
1012 disabledZoomWidth,
1013 uri + "?optionName=zoom&optionValue=width", //
1014 disabledZoomHeight,
1015 uri + "?optionName=zoom&optionValue=height" //
1016 ));
f433d153
NR
1017 }
1018
1019 appendPostHtml(builder);
1020 return NanoHTTPD.newFixedLengthResponse(Status.OK,
1021 NanoHTTPD.MIME_HTML, builder.toString());
1022 } catch (IOException e) {
1023 Instance.getInstance().getTraceHandler()
1024 .error(new IOException("Cannot get image: " + uri, e));
1025 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
1026 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
1027 }
1028 }
1029
1030 private Response newInputStreamResponse(String mimeType, InputStream in) {
1031 if (in == null) {
599b05c7
NR
1032 return NanoHTTPD.newFixedLengthResponse(Status.NO_CONTENT, "",
1033 null);
f433d153
NR
1034 }
1035 return NanoHTTPD.newChunkedResponse(Status.OK, mimeType, in);
1036 }
1037
1038 private String getContentOf(String file) {
599b05c7
NR
1039 InputStream in = IOUtils.openResource(WebLibraryServerIndex.class,
1040 file);
f433d153
NR
1041 if (in != null) {
1042 try {
1043 return IOUtils.readSmallStream(in);
1044 } catch (IOException e) {
599b05c7
NR
1045 Instance.getInstance().getTraceHandler().error(
1046 new IOException("Cannot get file: index.pre.html", e));
f433d153
NR
1047 }
1048 }
1049
1050 return "";
1051 }
1052
4c3426fb 1053 private String getViewUrl(String luid, Integer chap, Integer para) {
f433d153
NR
1054 return VIEWER_URL //
1055 .replace("{luid}", luid) //
4c3426fb 1056 .replace("/{chap}", chap == null ? "" : "/" + chap) //
f433d153 1057 .replace("/{para}",
4c3426fb 1058 (chap == null || para == null) ? "" : "/" + para);
f433d153
NR
1059 }
1060
1061 private String getStoryUrl(String luid, int chap, Integer para) {
1062 return STORY_URL //
1063 .replace("{luid}", luid) //
1064 .replace("{chap}", Integer.toString(chap)) //
1065 .replace("{para}", para == null ? "" : Integer.toString(para));
1066 }
1067
1068 private String getStoryUrlCover(String luid) {
1069 return STORY_URL_COVER //
1070 .replace("{luid}", luid);
1071 }
1072
d11fb35b
NR
1073 private boolean isAllowed(MetaData meta, LoginResult login) {
1074 if (login.isWl() && !whitelist.isEmpty()
1075 && !whitelist.contains(meta.getSource())) {
1076 return false;
1077 }
1078 if (login.isBl() && blacklist.contains(meta.getSource())) {
1079 return false;
1080 }
1081
1082 return true;
1083 }
1084
1085 private List<MetaData> metas(LoginResult login) throws IOException {
1086 BasicLibrary lib = Instance.getInstance().getLibrary();
aac272ee
NR
1087 System.out.println("Whitelist: " + whitelist);
1088 System.out.println("Blacklist: " + blacklist);
1089 System.out.println("isWl: " + login.isWl());
1090 System.out.println("isBl: " + login.isBl());
1091
d11fb35b
NR
1092 List<MetaData> metas = new ArrayList<MetaData>();
1093 for (MetaData meta : lib.getList().getMetas()) {
1094 if (isAllowed(meta, login)) {
1095 metas.add(meta);
1096 }
1097 }
1098
1099 return metas;
1100 }
1101
1102 private MetaData meta(String luid, LoginResult login) throws IOException {
f433d153
NR
1103 BasicLibrary lib = Instance.getInstance().getLibrary();
1104 MetaData meta = lib.getInfo(luid);
d11fb35b 1105 if (!isAllowed(meta, login))
f433d153 1106 return null;
f433d153
NR
1107
1108 return meta;
1109 }
1110
d11fb35b
NR
1111 private Image getCover(String luid, LoginResult login) throws IOException {
1112 MetaData meta = meta(luid, login);
f433d153 1113 if (meta != null) {
db9146bc
NR
1114 BasicLibrary lib = Instance.getInstance().getLibrary();
1115 return lib.getCover(meta.getLuid());
f433d153
NR
1116 }
1117
1118 return null;
1119 }
1120
1121 // NULL if not whitelist OK or if not found
d11fb35b 1122 private Story story(String luid, LoginResult login) throws IOException {
f433d153
NR
1123 synchronized (storyCache) {
1124 if (storyCache.containsKey(luid)) {
1125 Story story = storyCache.get(luid);
d11fb35b 1126 if (!isAllowed(story.getMeta(), login))
f433d153 1127 return null;
f433d153
NR
1128
1129 return story;
1130 }
1131 }
1132
1133 Story story = null;
d11fb35b 1134 MetaData meta = meta(luid, login);
f433d153
NR
1135 if (meta != null) {
1136 BasicLibrary lib = Instance.getInstance().getLibrary();
1137 story = lib.getStory(luid, null);
1138 long size = sizeOf(story);
1139
1140 synchronized (storyCache) {
1141 // Could have been added by another request
1142 if (!storyCache.containsKey(luid)) {
1143 while (!storyCacheOrder.isEmpty()
1144 && storyCacheSize + size > maxStoryCacheSize) {
1145 String oldestLuid = storyCacheOrder.removeFirst();
1146 Story oldestStory = storyCache.remove(oldestLuid);
1147 maxStoryCacheSize -= sizeOf(oldestStory);
1148 }
1149
1150 storyCacheOrder.add(luid);
1151 storyCache.put(luid, story);
1152 }
1153 }
1154 }
1155
1156 return story;
1157 }
1158
1159 private long sizeOf(Story story) {
1160 long size = 0;
1161 for (Chapter chap : story) {
1162 for (Paragraph para : chap) {
1163 if (para.getType() == ParagraphType.IMAGE) {
1164 size += para.getContentImage().getSize();
1165 } else {
1166 size += para.getContent().length();
1167 }
1168 }
1169 }
1170
1171 return size;
1172 }
1173
1174 private void appendPreHtml(StringBuilder builder, boolean banner) {
1175 String favicon = "favicon.ico";
1176 String icon = Instance.getInstance().getUiConfig()
1177 .getString(UiConfig.PROGRAM_ICON);
1178 if (icon != null) {
1179 favicon = "icon_" + icon.replace("-", "_") + ".png";
1180 }
1181
599b05c7
NR
1182 builder.append(
1183 getContentOf("index.pre.html").replace("favicon.ico", favicon));
f433d153
NR
1184
1185 if (banner) {
1186 builder.append("<div class='banner'>\n");
37d1f076 1187 builder.append("\t<img class='ico' src='/") //
f433d153
NR
1188 .append(favicon) //
1189 .append("'/>\n");
1190 builder.append("\t<h1>Fanfix</h1>\n");
1191 builder.append("\t<h2>") //
1192 .append(Version.getCurrentVersion()) //
1193 .append("</h2>\n");
1194 builder.append("</div>\n");
1195 }
1196 }
1197
1198 private void appendPostHtml(StringBuilder builder) {
1199 builder.append(getContentOf("index.post.html"));
1200 }
1201
1202 private void appendOption(StringBuilder builder, int depth, String name,
1203 String value, String selected) {
1204 for (int i = 0; i < depth; i++) {
1205 builder.append("\t");
1206 }
1207 builder.append("<option value='").append(value).append("'");
1208 if (value.equals(selected)) {
1209 builder.append(" selected='selected'");
1210 }
1211 builder.append(">").append(name).append("</option>\n");
1212 }
3fbc084c 1213
599b05c7
NR
1214 private void appendTableRow(StringBuilder builder, int depth,
1215 String... tds) {
1216 for (int i = 0; i < depth; i++) {
1217 builder.append("\t");
1218 }
1219
1220 int col = 1;
1221 builder.append("<tr>");
1222 for (String td : tds) {
1223 builder.append("<td class='col");
1224 builder.append(col++);
1225 builder.append("'>");
1226 builder.append(td);
1227 builder.append("</td>");
1228 }
1229 builder.append("</tr>\n");
1230 }
1231
6b89e45c
NR
1232 private void appendItemA(StringBuilder builder, int depth, String link,
1233 String name, boolean selected) {
1234 for (int i = 0; i < depth; i++) {
1235 builder.append("\t");
1236 }
1237
1238 builder.append("<a href='");
1239 builder.append(link);
1240 builder.append("' class='item goto");
1241 if (selected) {
1242 builder.append(" selected");
1243 }
1244 builder.append("'>");
1245 builder.append(name);
1246 builder.append("</a>\n");
1247 }
1248
3fbc084c
NR
1249 public static void main(String[] args) throws IOException {
1250 Instance.init();
1251 WebLibraryServer web = new WebLibraryServer(false);
1252 web.run();
1253 }
f433d153 1254}