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