New web library (http/https)
[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;
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 // refresh token
235 session.getCookies().set(new Cookie("token",
236 login.getToken(), "30; path=/"));
237
238 // set options
239 String optionName = params.get("optionName");
240 if (optionName != null && !optionName.isEmpty()) {
241 String optionValue = params.get("optionValue");
242 if (optionValue == null || optionValue.isEmpty()) {
243 session.getCookies().delete(optionName);
244 cookies.remove(optionName);
245 } else {
246 session.getCookies().set(new Cookie(optionName,
247 optionValue, "; path=/"));
248 cookies.put(optionName, optionValue);
249 }
250 }
251 }
252
253 Response rep = null;
254 if (!login.isSuccess() && (uri.equals("/") //
255 || uri.startsWith(STORY_URL_BASE) //
256 || uri.startsWith(VIEWER_URL_BASE) //
257 || uri.startsWith(LIST_URL))) {
258 rep = loginPage(login, uri);
259 }
260
261 if (rep == null) {
262 try {
263 if (uri.equals("/")) {
264 rep = root(session, cookies, whitelist);
265 } else if (uri.startsWith(LIST_URL)) {
266 rep = getList(uri, whitelist);
267 } else if (uri.startsWith(STORY_URL_BASE)) {
268 rep = getStoryPart(uri, whitelist);
269 } else if (uri.startsWith(VIEWER_URL_BASE)) {
270 rep = getViewer(cookies, uri, whitelist);
271 } else if (uri.equals("/logout")) {
272 session.getCookies().delete("token");
273 cookies.remove("token");
274 rep = loginPage(login, uri);
275 } else {
276 if (uri.startsWith("/"))
277 uri = uri.substring(1);
278 InputStream in = IOUtils.openResource(
279 WebLibraryServerIndex.class, uri);
280 if (in != null) {
281 String mimeType = MIME_PLAINTEXT;
282 if (uri.endsWith(".css")) {
283 mimeType = "text/css";
284 } else if (uri.endsWith(".html")) {
285 mimeType = "text/html";
286 } else if (uri.endsWith(".js")) {
287 mimeType = "text/javascript";
288 }
289 rep = newChunkedResponse(Status.OK, mimeType,
290 in);
291 } else {
292 getTraceHandler().trace("404: " + uri);
293 }
294 }
295
296 if (rep == null) {
297 rep = newFixedLengthResponse(Status.NOT_FOUND,
298 NanoHTTPD.MIME_PLAINTEXT, "Not Found");
299 }
300 } catch (Exception e) {
301 Instance.getInstance().getTraceHandler().error(
302 new IOException("Cannot process web request",
303 e));
304 rep = newFixedLengthResponse(Status.INTERNAL_ERROR,
305 NanoHTTPD.MIME_PLAINTEXT, "An error occured");
306 }
307 }
308
309 return rep;
310
311 // Get status: for story, use "luid" + active map of current
312 // luids
313 // map must use a addRef/removeRef and delete at 0
314
315 // http://localhost:2000/?token=ok
316
317 //
318 // MetaData meta = new MetaData();
319 // meta.setTitle("Title");
320 // meta.setLuid("000");
321 //
322 // JSONObject json = new JSONObject();
323 // json.put("", MetaData.class.getName());
324 // json.put("title", meta.getTitle());
325 // json.put("luid", meta.getLuid());
326 //
327 // return newFixedLengthResponse(json.toString());
328 }
329 };
330
331 if (ssf != null) {
332 getTraceHandler().trace("Install SSL on the web server...");
333 server.makeSecure(ssf, null);
334 getTraceHandler().trace("Done.");
335 }
336 }
337
338 @Override
339 public void run() {
340 try {
341 server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
342 } catch (IOException e) {
343 tracer.error(new IOException("Cannot start the web server", e));
344 }
345 }
346
347 /**
348 * Start the server (listen on the network for new connections).
349 * <p>
350 * Can only be called once.
351 * <p>
352 * This call is asynchronous, and will just start a new {@link Thread} on
353 * itself (see {@link WebLibraryServer#run()}).
354 */
355 public void start() {
356 new Thread(this).start();
357 }
358
359 /**
360 * The traces handler for this {@link WebLibraryServer}.
361 *
362 * @return the traces handler
363 */
364 public TraceHandler getTraceHandler() {
365 return tracer;
366 }
367
368 /**
369 * The traces handler for this {@link WebLibraryServer}.
370 *
371 * @param tracer
372 * the new traces handler
373 */
374 public void setTraceHandler(TraceHandler tracer) {
375 if (tracer == null) {
376 tracer = new TraceHandler(false, false, false);
377 }
378
379 this.tracer = tracer;
380 }
381
382 private LoginResult login(String who, String token, List<String> subkeys) {
383 String realKey = Instance.getInstance().getConfig()
384 .getString(Config.SERVER_KEY);
385 realKey = realKey == null ? "" : realKey;
386 return new LoginResult(token, who, realKey, subkeys);
387 }
388
389 // allow rw/wl
390 private LoginResult login(String who, String key, String subkey,
391 List<String> whitelist) {
392 String realKey = Instance.getInstance().getConfig()
393 .getString(Config.SERVER_KEY);
394
395 // I don't like NULLs...
396 realKey = realKey == null ? "" : realKey;
397 key = key == null ? "" : key;
398 subkey = subkey == null ? "" : subkey;
399
400 if (!realKey.equals(key)) {
401 return new LoginResult(null, null, null, false, false, false);
402 }
403
404 // defaults are positive (as previous versions without the feature)
405 boolean rw = true;
406 boolean wl = true;
407
408 if (whitelist.isEmpty()) {
409 wl = false;
410 }
411
412 rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
413 rw);
414 if (!subkey.isEmpty()) {
415 List<String> allowed = Instance.getInstance().getConfig()
416 .getList(Config.SERVER_ALLOWED_SUBKEYS);
417 if (allowed != null && allowed.contains(subkey)) {
418 if ((subkey + "|").contains("|rw|")) {
419 rw = true;
420 }
421 if ((subkey + "|").contains("|wl|")) {
422 wl = false; // |wl| = bypass whitelist
423 }
424 } else {
425 return new LoginResult(null, null, null, false, false, false);
426 }
427 }
428
429 return new LoginResult(who, key, subkey, true, rw, wl);
430 }
431
432 private Response loginPage(LoginResult login, String uri) {
433 StringBuilder builder = new StringBuilder();
434
435 appendPreHtml(builder, true);
436
437 if (login.isBadLogin()) {
438 builder.append("<div class='error'>Bad login or password</div>");
439 } else if (login.isBadToken()) {
440 builder.append("<div class='error'>Your session timed out</div>");
441 }
442
443 if (uri.equals("/logout")) {
444 uri = "/";
445 }
446
447 builder.append(
448 "<form method='POST' action='" + uri + "' class='login'>\n");
449 builder.append(
450 "<p>You must be logged into the system to see the stories.</p>");
451 builder.append("\t<input type='text' name='login' />\n");
452 builder.append("\t<input type='password' name='password' />\n");
453 builder.append("\t<input type='submit' value='Login' />\n");
454 builder.append("</form>\n");
455
456 appendPostHtml(builder);
457
458 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
459 NanoHTTPD.MIME_HTML, builder.toString());
460 }
461
462 protected Response getList(String uri, List<String> whitelist)
463 throws IOException {
464 if (uri.equals("/list/luids")) {
465 BasicLibrary lib = Instance.getInstance().getLibrary();
466 List<MetaData> metas = lib.getList().filter(whitelist, null, null);
467 List<JSONObject> jsons = new ArrayList<JSONObject>();
468 for (MetaData meta : metas) {
469 jsons.add(JsonIO.toJson(meta));
470 }
471
472 return newInputStreamResponse("application/json",
473 new ByteArrayInputStream(
474 new JSONArray(jsons).toString().getBytes()));
475 }
476
477 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
478 NanoHTTPD.MIME_PLAINTEXT, null);
479 }
480
481 private Response root(IHTTPSession session, Map<String, String> cookies,
482 List<String> whitelist) throws IOException {
483 BasicLibrary lib = Instance.getInstance().getLibrary();
484 MetaResultList result = lib.getList();
485 result = new MetaResultList(result.filter(whitelist, null, null));
486 StringBuilder builder = new StringBuilder();
487
488 appendPreHtml(builder, true);
489
490 String filter = cookies.get("filter");
491 if (filter == null) {
492 filter = "";
493 }
494
495 Map<String, String> params = session.getParms();
496 String browser = params.get("browser") == null ? ""
497 : params.get("browser");
498 String browser2 = params.get("browser2") == null ? ""
499 : params.get("browser2");
500 String browser3 = params.get("browser3") == null ? ""
501 : params.get("browser3");
502
503 String filterSource = null;
504 String filterAuthor = null;
505 String filterTag = null;
506
507 // TODO: javascript in realtime, using visible=false + hide [submit]
508
509 builder.append("<form class='browser'>\n");
510 builder.append("<div class='breadcrumbs'>\n");
511
512 builder.append("\t<select name='browser'>");
513 appendOption(builder, 2, "", "", browser);
514 appendOption(builder, 2, "Sources", "sources", browser);
515 appendOption(builder, 2, "Authors", "authors", browser);
516 appendOption(builder, 2, "Tags", "tags", browser);
517 builder.append("\t</select>\n");
518
519 if (!browser.isEmpty()) {
520 builder.append("\t<select name='browser2'>");
521 if (browser.equals("sources")) {
522 filterSource = browser2.isEmpty() ? filterSource : browser2;
523 // TODO: if 1 group -> no group
524 appendOption(builder, 2, "", "", browser2);
525 Map<String, List<String>> sources = result.getSourcesGrouped();
526 for (String source : sources.keySet()) {
527 appendOption(builder, 2, source, source, browser2);
528 }
529 } else if (browser.equals("authors")) {
530 filterAuthor = browser2.isEmpty() ? filterAuthor : browser2;
531 // TODO: if 1 group -> no group
532 appendOption(builder, 2, "", "", browser2);
533 Map<String, List<String>> authors = result.getAuthorsGrouped();
534 for (String author : authors.keySet()) {
535 appendOption(builder, 2, author, author, browser2);
536 }
537 } else if (browser.equals("tags")) {
538 filterTag = browser2.isEmpty() ? filterTag : browser2;
539 appendOption(builder, 2, "", "", browser2);
540 for (String tag : result.getTags()) {
541 appendOption(builder, 2, tag, tag, browser2);
542 }
543 }
544 builder.append("\t</select>\n");
545 }
546
547 if (!browser2.isEmpty()) {
548 if (browser.equals("sources")) {
549 filterSource = browser3.isEmpty() ? filterSource : browser3;
550 Map<String, List<String>> sourcesGrouped = result
551 .getSourcesGrouped();
552 List<String> sources = sourcesGrouped.get(browser2);
553 if (sources != null && !sources.isEmpty()) {
554 // TODO: single empty value
555 builder.append("\t<select name='browser3'>");
556 appendOption(builder, 2, "", "", browser3);
557 for (String source : sources) {
558 appendOption(builder, 2, source, source, browser3);
559 }
560 builder.append("\t</select>\n");
561 }
562 } else if (browser.equals("authors")) {
563 filterAuthor = browser3.isEmpty() ? filterAuthor : browser3;
564 Map<String, List<String>> authorsGrouped = result
565 .getAuthorsGrouped();
566 List<String> authors = authorsGrouped.get(browser2);
567 if (authors != null && !authors.isEmpty()) {
568 // TODO: single empty value
569 builder.append("\t<select name='browser3'>");
570 appendOption(builder, 2, "", "", browser3);
571 for (String author : authors) {
572 appendOption(builder, 2, author, author, browser3);
573 }
574 builder.append("\t</select>\n");
575 }
576 }
577 }
578
579 builder.append("\t<input type='submit' value='Select'/>\n");
580 builder.append("</div>\n");
581
582 // TODO: javascript in realtime, using visible=false + hide [submit]
583 builder.append("<div class='filter'>\n");
584 builder.append("\tFilter: \n");
585 builder.append(
586 "\t<input name='optionName' type='hidden' value='filter' />\n");
587 builder.append("\t<input name='optionValue' type='text' value='"
588 + filter + "' place-holder='...' />\n");
589 builder.append(
590 "\t<input name='submit' type='submit' value='Filter' />\n");
591 builder.append("</div>\n");
592 builder.append("</form>\n");
593
594 builder.append("\t<div class='books'>");
595 for (MetaData meta : result.getMetas()) {
596 if (!filter.isEmpty() && !meta.getTitle().toLowerCase()
597 .contains(filter.toLowerCase())) {
598 continue;
599 }
600
601 // TODO Sub sources
602 if (filterSource != null
603 && !filterSource.equals(meta.getSource())) {
604 continue;
605 }
606
607 // TODO: sub authors
608 if (filterAuthor != null
609 && !filterAuthor.equals(meta.getAuthor())) {
610 continue;
611 }
612
613 if (filterTag != null && !meta.getTags().contains(filterTag)) {
614 continue;
615 }
616
617 builder.append("<div class='book_line'>");
618 builder.append("<a href='");
619 builder.append(getViewUrl(meta.getLuid(), 0, null));
620 builder.append("'");
621 builder.append(" class='link'>");
622
623 if (lib.isCached(meta.getLuid())) {
624 // â—‰ = &#9673;
625 builder.append(
626 "<span class='cache_icon cached'>&#9673;</span>");
627 } else {
628 // â—‹ = &#9675;
629 builder.append(
630 "<span class='cache_icon uncached'>&#9675;</span>");
631 }
632 builder.append("<span class='luid'>");
633 builder.append(meta.getLuid());
634 builder.append("</span>");
635 builder.append("<span class='title'>");
636 builder.append(meta.getTitle());
637 builder.append("</span>");
638 builder.append("<span class='author'>");
639 if (meta.getAuthor() != null && !meta.getAuthor().isEmpty()) {
640 builder.append("(").append(meta.getAuthor()).append(")");
641 }
642 builder.append("</span>");
643 builder.append("</a></div>\n");
644 }
645 builder.append("</div>");
646
647 appendPostHtml(builder);
648 return NanoHTTPD.newFixedLengthResponse(builder.toString());
649 }
650
651 // /story/luid/chapter/para <-- text/image
652 // /story/luid/cover <-- image
653 // /story/luid/metadata <-- json
654 private Response getStoryPart(String uri, List<String> whitelist) {
655 String[] cover = uri.split("/");
656 int off = 2;
657
658 if (cover.length < off + 2) {
659 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
660 NanoHTTPD.MIME_PLAINTEXT, null);
661 }
662
663 String luid = cover[off + 0];
664 String chapterStr = cover[off + 1];
665 String imageStr = cover.length < off + 3 ? null : cover[off + 2];
666
667 // 1-based (0 = desc)
668 int chapter = 0;
669 if (chapterStr != null && !"cover".equals(chapterStr)
670 && !"metadata".equals(chapterStr)) {
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)) {
700 Image img = getCover(luid, whitelist);
701 if (img != null) {
702 in = img.newInputStream();
703 }
704 } else if ("metadata".equals(chapterStr)) {
705 MetaData meta = meta(luid, whitelist);
706 JSONObject json = JsonIO.toJson(meta);
707 mimeType = "application/json";
708 in = new ByteArrayInputStream(json.toString().getBytes());
709 } else {
710 Story story = story(luid, whitelist);
711 if (story != null) {
712 if (chapter == 0) {
713 StringBuilder builder = new StringBuilder();
714 for (Paragraph p : story.getMeta().getResume()) {
715 if (builder.length() == 0) {
716 builder.append("\n");
717 }
718 builder.append(p.getContent());
719 }
720
721 in = new ByteArrayInputStream(
722 builder.toString().getBytes("utf-8"));
723 } else {
724 Paragraph para = story.getChapters().get(chapter - 1)
725 .getParagraphs().get(paragraph - 1);
726 Image img = para.getContentImage();
727 if (para.getType() == ParagraphType.IMAGE) {
728 // TODO: get correct image type
729 mimeType = "image/png";
730 in = img.newInputStream();
731 } else {
732 in = new ByteArrayInputStream(
733 para.getContent().getBytes("utf-8"));
734 }
735 }
736 }
737 }
738 } catch (IndexOutOfBoundsException e) {
739 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
740 NanoHTTPD.MIME_PLAINTEXT,
741 "Chapter or paragraph does not exist");
742 } catch (IOException e) {
743 Instance.getInstance().getTraceHandler()
744 .error(new IOException("Cannot get image: " + uri, e));
745 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
746 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
747 }
748
749 return newInputStreamResponse(mimeType, in);
750 }
751
752 private Response getViewer(Map<String, String> cookies, String uri,
753 List<String> whitelist) {
754 String[] cover = uri.split("/");
755 int off = 2;
756
757 if (cover.length < off + 2) {
758 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
759 NanoHTTPD.MIME_PLAINTEXT, null);
760 }
761
762 String type = cover[off + 0];
763 String luid = cover[off + 1];
764 String chapterStr = cover.length < off + 3 ? null : cover[off + 2];
765 String paragraphStr = cover.length < off + 4 ? null : cover[off + 3];
766
767 // 1-based (0 = desc)
768 int chapter = -1;
769 if (chapterStr != null) {
770 try {
771 chapter = Integer.parseInt(chapterStr);
772 if (chapter < 0) {
773 throw new NumberFormatException();
774 }
775 } catch (NumberFormatException e) {
776 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
777 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
778 }
779 }
780
781 // 1-based
782 int paragraph = 0;
783 if (paragraphStr != null) {
784 try {
785 paragraph = Integer.parseInt(paragraphStr);
786 if (paragraph <= 0) {
787 throw new NumberFormatException();
788 }
789 } catch (NumberFormatException e) {
790 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
791 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
792 }
793 }
794
795 try {
796 Story story = story(luid, whitelist);
797 if (story == null) {
798 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
799 NanoHTTPD.MIME_PLAINTEXT, "Story not found");
800 }
801
802 StringBuilder builder = new StringBuilder();
803 appendPreHtml(builder, false);
804
805 if (chapter < 0) {
806 builder.append(story);
807 } else {
808 if (chapter == 0) {
809 // TODO: description
810 chapter = 1;
811 }
812
813 Chapter chap = null;
814 try {
815 chap = story.getChapters().get(chapter - 1);
816 } catch (IndexOutOfBoundsException e) {
817 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
818 NanoHTTPD.MIME_PLAINTEXT, "Chapter not found");
819 }
820
821 if (story.getMeta().isImageDocument() && paragraph <= 0) {
822 paragraph = 1;
823 }
824
825 String first, previous, next, last;
826 String content;
827
828 if (paragraph <= 0) {
829 first = getViewUrl(luid, 1, null);
830 previous = getViewUrl(luid, (Math.max(chapter - 1, 1)),
831 null);
832 next = getViewUrl(luid,
833 (Math.min(chapter + 1, story.getChapters().size())),
834 null);
835 last = getViewUrl(luid, story.getChapters().size(), null);
836
837 content = "<div class='viewer text'>\n"
838 + new TextOutput(false).convert(chap, true)
839 + "</div>\n";
840 } else {
841 first = getViewUrl(luid, chapter, 1);
842 previous = getViewUrl(luid, chapter,
843 (Math.max(paragraph - 1, 1)));
844 next = getViewUrl(luid, chapter, (Math.min(paragraph + 1,
845 chap.getParagraphs().size())));
846 last = getViewUrl(luid, chapter,
847 chap.getParagraphs().size());
848
849 Paragraph para = null;
850 try {
851 para = chap.getParagraphs().get(paragraph - 1);
852 } catch (IndexOutOfBoundsException e) {
853 return NanoHTTPD.newFixedLengthResponse(
854 Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
855 "Paragraph not found");
856 }
857
858 if (para.getType() == ParagraphType.IMAGE) {
859 String zoomStyle = "max-width: 100%;";
860 String zoomOption = cookies.get("zoom");
861 if (zoomOption != null && !zoomOption.isEmpty()) {
862 if (zoomOption.equals("real")) {
863 zoomStyle = "";
864 } else if (zoomOption.equals("width")) {
865 zoomStyle = "max-width: 100%;";
866 } else if (zoomOption.equals("height")) {
867 // see height of navbar + optionbar
868 zoomStyle = "max-height: calc(100% - 128px);";
869 }
870 }
871 content = String.format("" //
872 + "<a class='viewer link' href='%s'>" //
873 + "<img class='viewer img' style='%s' src='%s'/>"
874 + "</a>", //
875 next, //
876 zoomStyle, //
877 getStoryUrl(luid, chapter, paragraph));
878 } else {
879 content = para.getContent();
880 }
881
882 }
883
884 builder.append(String.format("" //
885 + "<div class='bar navbar'>\n" //
886 + "\t<a class='button first' href='%s'>&lt;&lt;</a>\n"//
887 + "\t<a class='button previous' href='%s'>&lt;</a>\n"//
888 + "\t<a class='button next' href='%s'>&gt;</a>\n"//
889 + "\t<a class='button last' href='%s'>&gt;&gt;</a>\n"//
890 + "</div>\n" //
891 + "%s", //
892 first, //
893 previous, //
894 next, //
895 last, //
896 content //
897 ));
898
899 builder.append("<div class='bar optionbar ");
900 if (paragraph > 0) {
901 builder.append("s4");
902 } else {
903 builder.append("s1");
904 }
905 builder.append("'>\n");
906 builder.append(
907 " <a class='button back' href='/'>BACK</a>\n");
908
909 if (paragraph > 0) {
910 builder.append(String.format("" //
911 + "\t<a class='button zoomreal' href='%s'>REAL</a>\n"//
912 + "\t<a class='button zoomwidth' href='%s'>WIDTH</a>\n"//
913 + "\t<a class='button zoomheight' href='%s'>HEIGHT</a>\n"//
914 + "</div>\n", //
915 uri + "?optionName=zoom&optionValue=real", //
916 uri + "?optionName=zoom&optionValue=width", //
917 uri + "?optionName=zoom&optionValue=height" //
918 ));
919 }
920 }
921
922 appendPostHtml(builder);
923 return NanoHTTPD.newFixedLengthResponse(Status.OK,
924 NanoHTTPD.MIME_HTML, builder.toString());
925 } catch (IOException e) {
926 Instance.getInstance().getTraceHandler()
927 .error(new IOException("Cannot get image: " + uri, e));
928 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
929 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
930 }
931 }
932
933 private Response newInputStreamResponse(String mimeType, InputStream in) {
934 if (in == null) {
935 return NanoHTTPD.newFixedLengthResponse(Status.NO_CONTENT, "",
936 null);
937 }
938 return NanoHTTPD.newChunkedResponse(Status.OK, mimeType, in);
939 }
940
941 private String getContentOf(String file) {
942 InputStream in = IOUtils.openResource(WebLibraryServerIndex.class,
943 file);
944 if (in != null) {
945 try {
946 return IOUtils.readSmallStream(in);
947 } catch (IOException e) {
948 Instance.getInstance().getTraceHandler().error(
949 new IOException("Cannot get file: index.pre.html", e));
950 }
951 }
952
953 return "";
954 }
955
956 private String getViewUrl(String luid, int chap, Integer para) {
957 return VIEWER_URL //
958 .replace("{luid}", luid) //
959 .replace("{chap}", Integer.toString(chap)) //
960 .replace("/{para}",
961 para == null ? "" : "/" + Integer.toString(para));
962 }
963
964 private String getStoryUrl(String luid, int chap, Integer para) {
965 return STORY_URL //
966 .replace("{luid}", luid) //
967 .replace("{chap}", Integer.toString(chap)) //
968 .replace("{para}", para == null ? "" : Integer.toString(para));
969 }
970
971 private String getStoryUrlCover(String luid) {
972 return STORY_URL_COVER //
973 .replace("{luid}", luid);
974 }
975
976 private MetaData meta(String luid, List<String> whitelist)
977 throws IOException {
978 BasicLibrary lib = Instance.getInstance().getLibrary();
979 MetaData meta = lib.getInfo(luid);
980 if (!whitelist.isEmpty() && !whitelist.contains(meta.getSource())) {
981 return null;
982 }
983
984 return meta;
985 }
986
987 private Image getCover(String luid, List<String> whitelist)
988 throws IOException {
989 MetaData meta = meta(luid, whitelist);
990 if (meta != null) {
991 return meta.getCover();
992 }
993
994 return null;
995 }
996
997 // NULL if not whitelist OK or if not found
998 private Story story(String luid, List<String> whitelist)
999 throws IOException {
1000 synchronized (storyCache) {
1001 if (storyCache.containsKey(luid)) {
1002 Story story = storyCache.get(luid);
1003 if (!whitelist.isEmpty()
1004 && !whitelist.contains(story.getMeta().getSource())) {
1005 return null;
1006 }
1007
1008 return story;
1009 }
1010 }
1011
1012 Story story = null;
1013 MetaData meta = meta(luid, whitelist);
1014 if (meta != null) {
1015 BasicLibrary lib = Instance.getInstance().getLibrary();
1016 story = lib.getStory(luid, null);
1017 long size = sizeOf(story);
1018
1019 synchronized (storyCache) {
1020 // Could have been added by another request
1021 if (!storyCache.containsKey(luid)) {
1022 while (!storyCacheOrder.isEmpty()
1023 && storyCacheSize + size > maxStoryCacheSize) {
1024 String oldestLuid = storyCacheOrder.removeFirst();
1025 Story oldestStory = storyCache.remove(oldestLuid);
1026 maxStoryCacheSize -= sizeOf(oldestStory);
1027 }
1028
1029 storyCacheOrder.add(luid);
1030 storyCache.put(luid, story);
1031 }
1032 }
1033 }
1034
1035 return story;
1036 }
1037
1038 private long sizeOf(Story story) {
1039 long size = 0;
1040 for (Chapter chap : story) {
1041 for (Paragraph para : chap) {
1042 if (para.getType() == ParagraphType.IMAGE) {
1043 size += para.getContentImage().getSize();
1044 } else {
1045 size += para.getContent().length();
1046 }
1047 }
1048 }
1049
1050 return size;
1051 }
1052
1053 private void appendPreHtml(StringBuilder builder, boolean banner) {
1054 String favicon = "favicon.ico";
1055 String icon = Instance.getInstance().getUiConfig()
1056 .getString(UiConfig.PROGRAM_ICON);
1057 if (icon != null) {
1058 favicon = "icon_" + icon.replace("-", "_") + ".png";
1059 }
1060
1061 builder.append(
1062 getContentOf("index.pre.html").replace("favicon.ico", favicon));
1063
1064 if (banner) {
1065 builder.append("<div class='banner'>\n");
1066 builder.append("\t<img class='ico' src='") //
1067 .append(favicon) //
1068 .append("'/>\n");
1069 builder.append("\t<h1>Fanfix</h1>\n");
1070 builder.append("\t<h2>") //
1071 .append(Version.getCurrentVersion()) //
1072 .append("</h2>\n");
1073 builder.append("</div>\n");
1074 }
1075 }
1076
1077 private void appendPostHtml(StringBuilder builder) {
1078 builder.append(getContentOf("index.post.html"));
1079 }
1080
1081 private void appendOption(StringBuilder builder, int depth, String name,
1082 String value, String selected) {
1083 for (int i = 0; i < depth; i++) {
1084 builder.append("\t");
1085 }
1086 builder.append("<option value='").append(value).append("'");
1087 if (value.equals(selected)) {
1088 builder.append(" selected='selected'");
1089 }
1090 builder.append(">").append(name).append("</option>\n");
1091 }
1092}