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