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