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