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