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