fix cover image, prepare desc
[nikiroo-utils.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
c5103223 658 // /story/luid/json <-- json, whole chapter (no images)
f433d153
NR
659 private Response getStoryPart(String uri, List<String> whitelist) {
660 String[] cover = uri.split("/");
661 int off = 2;
662
663 if (cover.length < off + 2) {
664 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
665 NanoHTTPD.MIME_PLAINTEXT, null);
666 }
667
668 String luid = cover[off + 0];
669 String chapterStr = cover[off + 1];
670 String imageStr = cover.length < off + 3 ? null : cover[off + 2];
671
672 // 1-based (0 = desc)
673 int chapter = 0;
674 if (chapterStr != null && !"cover".equals(chapterStr)
3fbc084c
NR
675 && !"metadata".equals(chapterStr)
676 && !"json".equals(chapterStr)) {
f433d153
NR
677 try {
678 chapter = Integer.parseInt(chapterStr);
679 if (chapter < 0) {
680 throw new NumberFormatException();
681 }
682 } catch (NumberFormatException e) {
683 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
684 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
685 }
686 }
687
688 // 1-based
689 int paragraph = 1;
690 if (imageStr != null) {
691 try {
692 paragraph = Integer.parseInt(imageStr);
693 if (paragraph < 0) {
694 throw new NumberFormatException();
695 }
696 } catch (NumberFormatException e) {
697 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
698 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
699 }
700 }
701
702 String mimeType = NanoHTTPD.MIME_PLAINTEXT;
703 InputStream in = null;
704 try {
705 if ("cover".equals(chapterStr)) {
706 Image img = getCover(luid, whitelist);
707 if (img != null) {
708 in = img.newInputStream();
709 }
710 } else if ("metadata".equals(chapterStr)) {
711 MetaData meta = meta(luid, whitelist);
712 JSONObject json = JsonIO.toJson(meta);
713 mimeType = "application/json";
714 in = new ByteArrayInputStream(json.toString().getBytes());
3fbc084c 715 } else if ("json".equals(chapterStr)) {
c5103223
NR
716 Story story = story(luid, whitelist);
717 JSONObject json = JsonIO.toJson(story);
718 mimeType = "application/json";
719 in = new ByteArrayInputStream(json.toString().getBytes());
f433d153
NR
720 } else {
721 Story story = story(luid, whitelist);
722 if (story != null) {
723 if (chapter == 0) {
724 StringBuilder builder = new StringBuilder();
725 for (Paragraph p : story.getMeta().getResume()) {
726 if (builder.length() == 0) {
727 builder.append("\n");
728 }
729 builder.append(p.getContent());
730 }
731
732 in = new ByteArrayInputStream(
733 builder.toString().getBytes("utf-8"));
734 } else {
735 Paragraph para = story.getChapters().get(chapter - 1)
736 .getParagraphs().get(paragraph - 1);
737 Image img = para.getContentImage();
738 if (para.getType() == ParagraphType.IMAGE) {
739 // TODO: get correct image type
740 mimeType = "image/png";
741 in = img.newInputStream();
742 } else {
743 in = new ByteArrayInputStream(
744 para.getContent().getBytes("utf-8"));
745 }
746 }
747 }
748 }
749 } catch (IndexOutOfBoundsException e) {
750 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
751 NanoHTTPD.MIME_PLAINTEXT,
752 "Chapter or paragraph does not exist");
753 } catch (IOException e) {
754 Instance.getInstance().getTraceHandler()
755 .error(new IOException("Cannot get image: " + uri, e));
756 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
757 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
758 }
759
760 return newInputStreamResponse(mimeType, in);
761 }
762
763 private Response getViewer(Map<String, String> cookies, String uri,
764 List<String> whitelist) {
765 String[] cover = uri.split("/");
766 int off = 2;
767
768 if (cover.length < off + 2) {
769 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
770 NanoHTTPD.MIME_PLAINTEXT, null);
771 }
772
773 String type = cover[off + 0];
774 String luid = cover[off + 1];
775 String chapterStr = cover.length < off + 3 ? null : cover[off + 2];
776 String paragraphStr = cover.length < off + 4 ? null : cover[off + 3];
777
778 // 1-based (0 = desc)
779 int chapter = -1;
780 if (chapterStr != null) {
781 try {
782 chapter = Integer.parseInt(chapterStr);
783 if (chapter < 0) {
784 throw new NumberFormatException();
785 }
786 } catch (NumberFormatException e) {
787 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
788 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
789 }
790 }
791
792 // 1-based
793 int paragraph = 0;
794 if (paragraphStr != null) {
795 try {
796 paragraph = Integer.parseInt(paragraphStr);
797 if (paragraph <= 0) {
798 throw new NumberFormatException();
799 }
800 } catch (NumberFormatException e) {
801 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
802 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
803 }
804 }
805
806 try {
807 Story story = story(luid, whitelist);
808 if (story == null) {
809 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
810 NanoHTTPD.MIME_PLAINTEXT, "Story not found");
811 }
812
813 StringBuilder builder = new StringBuilder();
814 appendPreHtml(builder, false);
815
db9146bc
NR
816 // TODO: no desc page for images?
817 if (story.getMeta().isImageDocument()) {
818 if (chapter <= 0)
f433d153 819 chapter = 1;
db9146bc
NR
820 if (paragraph <= 0)
821 paragraph = 1;
822 }
f433d153 823
db9146bc
NR
824 Chapter chap = null;
825 if (chapter <= 0) {
826 chap = story.getMeta().getResume();
827 } else {
f433d153
NR
828 try {
829 chap = story.getChapters().get(chapter - 1);
830 } catch (IndexOutOfBoundsException e) {
831 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
832 NanoHTTPD.MIME_PLAINTEXT, "Chapter not found");
833 }
db9146bc 834 }
f433d153 835
db9146bc
NR
836 String first, previous, next, last;
837
838 StringBuilder content = new StringBuilder();
839
840 String disabledLeft = "";
841 String disabledRight = "";
842 String disabledZoomReal = "";
843 String disabledZoomWidth = "";
844 String disabledZoomHeight = "";
845
846 if (paragraph <= 0) {
847 first = getViewUrl(luid, 1, null);
848 previous = getViewUrl(luid, (Math.max(chapter - 1, 1)), null);
849 next = getViewUrl(luid,
850 (Math.min(chapter + 1, story.getChapters().size())),
851 null);
852 last = getViewUrl(luid, story.getChapters().size(), null);
853
854 // TODO
855 StringBuilder desc = new StringBuilder();
856
857 if (chapter <= 0) {
858 desc.append("<div class='desc'>\n");
859 desc.append("\t<div class='cover'>\n");
860 desc.append("\t\t<img src='/story/" + luid + "/cover'/>\n");
861 desc.append("\t</div>\n");
862 desc.append("\t<table>\n");
863 desc.append(
864 "\t\t<tr><th>HEAD 1</th><th>HEAD 2</th></tr>\n");
865 desc.append("\t\t<tr><td>KEY 1</td><td>VAL 1</td></tr>\n");
866 desc.append("\t\t<tr><td>KEY 2</td><td>VAL 2</td></tr>\n");
867 desc.append("\t</table>\n");
868 desc.append("</div>\n");
869 desc.append("<h1 class='title'>Description</h1>\n");
f433d153
NR
870 }
871
db9146bc
NR
872 content.append("<div class='viewer text'>\n");
873 content.append(desc);
874 content.append(
875 new TextOutput(false).convert(chap, chapter > 0));
876 content.append("</div>\n");
3fbc084c 877
db9146bc
NR
878 if (chapter <= 1)
879 disabledLeft = " disabled='disbaled'";
880 if (chapter >= story.getChapters().size())
881 disabledRight = " disabled='disbaled'";
882 } else {
883 first = getViewUrl(luid, chapter, 1);
884 previous = getViewUrl(luid, chapter,
885 (Math.max(paragraph - 1, 1)));
886 next = getViewUrl(luid, chapter,
887 (Math.min(paragraph + 1, chap.getParagraphs().size())));
888 last = getViewUrl(luid, chapter, chap.getParagraphs().size());
889
890 if (paragraph <= 1)
891 disabledLeft = " disabled='disbaled'";
892 if (paragraph >= chap.getParagraphs().size())
893 disabledRight = " disabled='disbaled'";
894
895 Paragraph para = null;
896 try {
897 para = chap.getParagraphs().get(paragraph - 1);
898 } catch (IndexOutOfBoundsException e) {
899 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
900 NanoHTTPD.MIME_PLAINTEXT,
901 "Paragraph " + paragraph + " not found");
902 }
f433d153 903
db9146bc
NR
904 if (para.getType() == ParagraphType.IMAGE) {
905 String zoomStyle = "max-width: 100%;";
906 disabledZoomWidth = " disabled='disabled'";
907 String zoomOption = cookies.get("zoom");
908 if (zoomOption != null && !zoomOption.isEmpty()) {
909 if (zoomOption.equals("real")) {
910 zoomStyle = "";
911 disabledZoomWidth = "";
912 disabledZoomReal = " disabled='disabled'";
913 } else if (zoomOption.equals("width")) {
914 zoomStyle = "max-width: 100%;";
915 } else if (zoomOption.equals("height")) {
916 // see height of navbar + optionbar
917 zoomStyle = "max-height: calc(100% - 128px);";
918 disabledZoomWidth = "";
919 disabledZoomHeight = " disabled='disabled'";
f433d153 920 }
f433d153
NR
921 }
922
db9146bc
NR
923 content.append(String.format("" //
924 + "<a class='viewer link' href='%s'>"
925 + "<img class='viewer img' style='%s' src='%s'/>"
926 + "</a>", //
927 next, //
928 zoomStyle, //
929 getStoryUrl(luid, chapter, paragraph)));
930 } else {
931 content.append(para.getContent());
f433d153 932 }
db9146bc 933 }
f433d153 934
db9146bc
NR
935 builder.append(String.format("" //
936 + "<div class='bar navbar'>\n" //
937 + "\t<a%s class='button first' href='%s'>&lt;&lt;</a>\n"//
938 + "\t<a%s class='button previous' href='%s'>&lt;</a>\n"//
939 + "\t<a%s class='button next' href='%s'>&gt;</a>\n"//
940 + "\t<a%s class='button last' href='%s'>&gt;&gt;</a>\n"//
941 + "</div>\n", //
942 disabledLeft, first, //
943 disabledLeft, previous, //
944 disabledRight, next, //
945 disabledRight, last //
946 ));
947
948 builder.append(content);
949
950 builder.append("<div class='bar optionbar ");
951 if (paragraph > 0) {
952 builder.append("s4");
953 } else {
954 builder.append("s1");
955 }
956 builder.append("'>\n");
957 builder.append(" <a class='button back' href='/'>BACK</a>\n");
958
959 if (paragraph > 0) {
f433d153 960 builder.append(String.format("" //
db9146bc
NR
961 + "\t<a%s class='button zoomreal' href='%s'>REAL</a>\n"//
962 + "\t<a%s class='button zoomwidth' href='%s'>WIDTH</a>\n"//
963 + "\t<a%s class='button zoomheight' href='%s'>HEIGHT</a>\n"//
964 + "</div>\n", //
965 disabledZoomReal,
966 uri + "?optionName=zoom&optionValue=real", //
967 disabledZoomWidth,
968 uri + "?optionName=zoom&optionValue=width", //
969 disabledZoomHeight,
970 uri + "?optionName=zoom&optionValue=height" //
f433d153 971 ));
f433d153
NR
972 }
973
974 appendPostHtml(builder);
975 return NanoHTTPD.newFixedLengthResponse(Status.OK,
976 NanoHTTPD.MIME_HTML, builder.toString());
977 } catch (IOException e) {
978 Instance.getInstance().getTraceHandler()
979 .error(new IOException("Cannot get image: " + uri, e));
980 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
981 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
982 }
983 }
984
985 private Response newInputStreamResponse(String mimeType, InputStream in) {
986 if (in == null) {
987 return NanoHTTPD.newFixedLengthResponse(Status.NO_CONTENT, "",
988 null);
989 }
990 return NanoHTTPD.newChunkedResponse(Status.OK, mimeType, in);
991 }
992
993 private String getContentOf(String file) {
994 InputStream in = IOUtils.openResource(WebLibraryServerIndex.class,
995 file);
996 if (in != null) {
997 try {
998 return IOUtils.readSmallStream(in);
999 } catch (IOException e) {
1000 Instance.getInstance().getTraceHandler().error(
1001 new IOException("Cannot get file: index.pre.html", e));
1002 }
1003 }
1004
1005 return "";
1006 }
1007
1008 private String getViewUrl(String luid, int chap, Integer para) {
1009 return VIEWER_URL //
1010 .replace("{luid}", luid) //
1011 .replace("{chap}", Integer.toString(chap)) //
1012 .replace("/{para}",
1013 para == null ? "" : "/" + Integer.toString(para));
1014 }
1015
1016 private String getStoryUrl(String luid, int chap, Integer para) {
1017 return STORY_URL //
1018 .replace("{luid}", luid) //
1019 .replace("{chap}", Integer.toString(chap)) //
1020 .replace("{para}", para == null ? "" : Integer.toString(para));
1021 }
1022
1023 private String getStoryUrlCover(String luid) {
1024 return STORY_URL_COVER //
1025 .replace("{luid}", luid);
1026 }
1027
1028 private MetaData meta(String luid, List<String> whitelist)
1029 throws IOException {
1030 BasicLibrary lib = Instance.getInstance().getLibrary();
1031 MetaData meta = lib.getInfo(luid);
1032 if (!whitelist.isEmpty() && !whitelist.contains(meta.getSource())) {
1033 return null;
1034 }
1035
1036 return meta;
1037 }
1038
1039 private Image getCover(String luid, List<String> whitelist)
1040 throws IOException {
1041 MetaData meta = meta(luid, whitelist);
1042 if (meta != null) {
db9146bc
NR
1043 BasicLibrary lib = Instance.getInstance().getLibrary();
1044 return lib.getCover(meta.getLuid());
f433d153
NR
1045 }
1046
1047 return null;
1048 }
1049
1050 // NULL if not whitelist OK or if not found
1051 private Story story(String luid, List<String> whitelist)
1052 throws IOException {
1053 synchronized (storyCache) {
1054 if (storyCache.containsKey(luid)) {
1055 Story story = storyCache.get(luid);
1056 if (!whitelist.isEmpty()
1057 && !whitelist.contains(story.getMeta().getSource())) {
1058 return null;
1059 }
1060
1061 return story;
1062 }
1063 }
1064
1065 Story story = null;
1066 MetaData meta = meta(luid, whitelist);
1067 if (meta != null) {
1068 BasicLibrary lib = Instance.getInstance().getLibrary();
1069 story = lib.getStory(luid, null);
1070 long size = sizeOf(story);
1071
1072 synchronized (storyCache) {
1073 // Could have been added by another request
1074 if (!storyCache.containsKey(luid)) {
1075 while (!storyCacheOrder.isEmpty()
1076 && storyCacheSize + size > maxStoryCacheSize) {
1077 String oldestLuid = storyCacheOrder.removeFirst();
1078 Story oldestStory = storyCache.remove(oldestLuid);
1079 maxStoryCacheSize -= sizeOf(oldestStory);
1080 }
1081
1082 storyCacheOrder.add(luid);
1083 storyCache.put(luid, story);
1084 }
1085 }
1086 }
1087
1088 return story;
1089 }
1090
1091 private long sizeOf(Story story) {
1092 long size = 0;
1093 for (Chapter chap : story) {
1094 for (Paragraph para : chap) {
1095 if (para.getType() == ParagraphType.IMAGE) {
1096 size += para.getContentImage().getSize();
1097 } else {
1098 size += para.getContent().length();
1099 }
1100 }
1101 }
1102
1103 return size;
1104 }
1105
1106 private void appendPreHtml(StringBuilder builder, boolean banner) {
1107 String favicon = "favicon.ico";
1108 String icon = Instance.getInstance().getUiConfig()
1109 .getString(UiConfig.PROGRAM_ICON);
1110 if (icon != null) {
1111 favicon = "icon_" + icon.replace("-", "_") + ".png";
1112 }
1113
1114 builder.append(
1115 getContentOf("index.pre.html").replace("favicon.ico", favicon));
1116
1117 if (banner) {
1118 builder.append("<div class='banner'>\n");
37d1f076 1119 builder.append("\t<img class='ico' src='/") //
f433d153
NR
1120 .append(favicon) //
1121 .append("'/>\n");
1122 builder.append("\t<h1>Fanfix</h1>\n");
1123 builder.append("\t<h2>") //
1124 .append(Version.getCurrentVersion()) //
1125 .append("</h2>\n");
1126 builder.append("</div>\n");
1127 }
1128 }
1129
1130 private void appendPostHtml(StringBuilder builder) {
1131 builder.append(getContentOf("index.post.html"));
1132 }
1133
1134 private void appendOption(StringBuilder builder, int depth, String name,
1135 String value, String selected) {
1136 for (int i = 0; i < depth; i++) {
1137 builder.append("\t");
1138 }
1139 builder.append("<option value='").append(value).append("'");
1140 if (value.equals(selected)) {
1141 builder.append(" selected='selected'");
1142 }
1143 builder.append(">").append(name).append("</option>\n");
1144 }
3fbc084c
NR
1145
1146 public static void main(String[] args) throws IOException {
1147 Instance.init();
1148 WebLibraryServer web = new WebLibraryServer(false);
1149 web.run();
1150 }
f433d153 1151}