1 package be
.nikiroo
.fanfix
.library
;
3 import java
.io
.ByteArrayInputStream
;
5 import java
.io
.FileInputStream
;
6 import java
.io
.IOException
;
7 import java
.io
.InputStream
;
8 import java
.security
.KeyStore
;
9 import java
.util
.ArrayList
;
10 import java
.util
.HashMap
;
11 import java
.util
.LinkedList
;
12 import java
.util
.List
;
15 import javax
.net
.ssl
.KeyManagerFactory
;
16 import javax
.net
.ssl
.SSLServerSocketFactory
;
18 import org
.json
.JSONArray
;
19 import org
.json
.JSONObject
;
21 import be
.nikiroo
.fanfix
.Instance
;
22 import be
.nikiroo
.fanfix
.bundles
.Config
;
23 import be
.nikiroo
.fanfix
.bundles
.UiConfig
;
24 import be
.nikiroo
.fanfix
.data
.Chapter
;
25 import be
.nikiroo
.fanfix
.data
.JsonIO
;
26 import be
.nikiroo
.fanfix
.data
.MetaData
;
27 import be
.nikiroo
.fanfix
.data
.Paragraph
;
28 import be
.nikiroo
.fanfix
.data
.Paragraph
.ParagraphType
;
29 import be
.nikiroo
.fanfix
.data
.Story
;
30 import be
.nikiroo
.fanfix
.library
.web
.WebLibraryServerIndex
;
31 import be
.nikiroo
.fanfix
.reader
.TextOutput
;
32 import be
.nikiroo
.utils
.CookieUtils
;
33 import be
.nikiroo
.utils
.IOUtils
;
34 import be
.nikiroo
.utils
.Image
;
35 import be
.nikiroo
.utils
.NanoHTTPD
;
36 import be
.nikiroo
.utils
.NanoHTTPD
.IHTTPSession
;
37 import be
.nikiroo
.utils
.NanoHTTPD
.Response
;
38 import be
.nikiroo
.utils
.NanoHTTPD
.Response
.Status
;
39 import be
.nikiroo
.utils
.TraceHandler
;
40 import be
.nikiroo
.utils
.Version
;
42 public class WebLibraryServer
implements Runnable
{
43 static private String VIEWER_URL_BASE
= "/view/story/";
44 static private String VIEWER_URL
= VIEWER_URL_BASE
+ "{luid}/{chap}/{para}";
45 static private String STORY_URL_BASE
= "/story/";
46 static private String STORY_URL
= STORY_URL_BASE
+ "{luid}/{chap}/{para}";
47 static private String STORY_URL_COVER
= STORY_URL_BASE
+ "{luid}/cover";
48 static private String LIST_URL
= "/list/";
50 private class LoginResult
{
51 private boolean success
;
55 private String wookie
;
57 private boolean badLogin
;
58 private boolean badToken
;
60 public LoginResult(String who
, String key
, String subkey
,
61 boolean success
, boolean rw
, boolean wl
, boolean bl
) {
62 this.success
= success
;
66 this.wookie
= CookieUtils
.generateCookie(who
+ key
, 0);
77 this.token
= wookie
+ "~"
78 + CookieUtils
.generateCookie(wookie
+ subkey
+ opts
, 0)
80 this.badLogin
= !success
;
83 public LoginResult(String token
, String who
, String key
,
84 List
<String
> subkeys
) {
87 String hashes
[] = token
.split("~");
88 if (hashes
.length
>= 2) {
89 String wookie
= hashes
[0];
90 String rehashed
= hashes
[1];
91 String opts
= hashes
.length
> 2 ? hashes
[2] : "";
93 if (CookieUtils
.validateCookie(who
+ key
, wookie
)) {
94 if (subkeys
== null) {
95 subkeys
= new ArrayList
<String
>();
97 subkeys
= new ArrayList
<String
>(subkeys
);
100 for (String subkey
: subkeys
) {
101 if (CookieUtils
.validateCookie(
102 wookie
+ subkey
+ opts
, rehashed
)) {
103 this.wookie
= wookie
;
107 this.rw
= opts
.contains("|rw|");
108 this.wl
= !opts
.contains("|wl|");
109 this.bl
= !opts
.contains("|bl|");
115 this.badToken
= !success
;
118 // No token -> no bad token
121 public boolean isSuccess() {
125 public boolean isRw() {
129 public boolean isWl() {
133 public boolean isBl() {
137 public String
getToken() {
141 public boolean isBadLogin() {
145 public boolean isBadToken() {
150 private NanoHTTPD server
;
151 private Map
<String
, Story
> storyCache
= new HashMap
<String
, Story
>();
152 private LinkedList
<String
> storyCacheOrder
= new LinkedList
<String
>();
153 private long storyCacheSize
= 0;
154 private long maxStoryCacheSize
;
155 private TraceHandler tracer
= new TraceHandler();
157 private List
<String
> whitelist
;
158 private List
<String
> blacklist
;
160 public WebLibraryServer(boolean secure
) throws IOException
{
161 Integer port
= Instance
.getInstance().getConfig()
162 .getInteger(Config
.SERVER_PORT
);
164 throw new IOException(
165 "Cannot start web server: port not specified");
168 int cacheMb
= Instance
.getInstance().getConfig()
169 .getInteger(Config
.SERVER_MAX_CACHE_MB
, 100);
170 maxStoryCacheSize
= cacheMb
* 1024 * 1024;
172 setTraceHandler(Instance
.getInstance().getTraceHandler());
174 whitelist
= Instance
.getInstance().getConfig()
175 .getList(Config
.SERVER_WHITELIST
, new ArrayList
<String
>());
176 blacklist
= Instance
.getInstance().getConfig()
177 .getList(Config
.SERVER_BLACKLIST
, new ArrayList
<String
>());
179 SSLServerSocketFactory ssf
= null;
181 String keystorePath
= Instance
.getInstance().getConfig()
182 .getString(Config
.SERVER_SSL_KEYSTORE
, "");
183 String keystorePass
= Instance
.getInstance().getConfig()
184 .getString(Config
.SERVER_SSL_KEYSTORE_PASS
);
186 if (secure
&& keystorePath
.isEmpty()) {
187 throw new IOException(
188 "Cannot start a secure web server: no keystore.jks file povided");
191 if (!keystorePath
.isEmpty()) {
192 File keystoreFile
= new File(keystorePath
);
194 KeyStore keystore
= KeyStore
195 .getInstance(KeyStore
.getDefaultType());
196 InputStream keystoreStream
= new FileInputStream(
199 keystore
.load(keystoreStream
,
200 keystorePass
.toCharArray());
201 KeyManagerFactory keyManagerFactory
= KeyManagerFactory
202 .getInstance(KeyManagerFactory
203 .getDefaultAlgorithm());
204 keyManagerFactory
.init(keystore
,
205 keystorePass
.toCharArray());
206 ssf
= NanoHTTPD
.makeSSLSocketFactory(keystore
,
209 keystoreStream
.close();
211 } catch (Exception e
) {
212 throw new IOException(e
.getMessage());
217 server
= new NanoHTTPD(port
) {
219 public Response
serve(final IHTTPSession session
) {
220 super.serve(session
);
222 String query
= session
.getQueryParameterString(); // a=a%20b&dd=2
223 Method method
= session
.getMethod(); // GET, POST..
224 String uri
= session
.getUri(); // /home.html
226 // need them in real time (not just those sent by the UA)
227 Map
<String
, String
> cookies
= new HashMap
<String
, String
>();
228 for (String cookie
: session
.getCookies()) {
229 cookies
.put(cookie
, session
.getCookies().read(cookie
));
232 LoginResult login
= null;
233 Map
<String
, String
> params
= session
.getParms();
234 String who
= session
.getRemoteHostName()
235 + session
.getRemoteIpAddress();
236 if (params
.get("login") != null) {
237 login
= login(who
, params
.get("password"),
238 params
.get("login"));
240 String token
= cookies
.get("token");
241 login
= login(who
, token
);
244 if (login
.isSuccess()) {
246 session
.getCookies().set(new Cookie("token",
247 login
.getToken(), "30; path=/"));
250 String optionName
= params
.get("optionName");
251 if (optionName
!= null && !optionName
.isEmpty()) {
252 String optionNo
= params
.get("optionNo");
253 String optionValue
= params
.get("optionValue");
254 if (optionNo
!= null || optionValue
== null
255 || optionValue
.isEmpty()) {
256 session
.getCookies().delete(optionName
);
257 cookies
.remove(optionName
);
259 session
.getCookies().set(new Cookie(optionName
,
260 optionValue
, "; path=/"));
261 cookies
.put(optionName
, optionValue
);
267 if (!login
.isSuccess() && (uri
.equals("/") //
268 || uri
.startsWith(STORY_URL_BASE
) //
269 || uri
.startsWith(VIEWER_URL_BASE
) //
270 || uri
.startsWith(LIST_URL
))) {
271 rep
= loginPage(login
, uri
);
276 if (uri
.equals("/")) {
277 rep
= root(session
, cookies
, login
);
278 } else if (uri
.startsWith(LIST_URL
)) {
279 rep
= getList(uri
, login
);
280 } else if (uri
.startsWith(STORY_URL_BASE
)) {
281 rep
= getStoryPart(uri
, login
);
282 } else if (uri
.startsWith(VIEWER_URL_BASE
)) {
283 rep
= getViewer(cookies
, uri
, login
);
284 } else if (uri
.equals("/logout")) {
285 session
.getCookies().delete("token");
286 cookies
.remove("token");
287 rep
= loginPage(login
, uri
);
289 if (uri
.startsWith("/"))
290 uri
= uri
.substring(1);
291 InputStream in
= IOUtils
.openResource(
292 WebLibraryServerIndex
.class, uri
);
294 String mimeType
= MIME_PLAINTEXT
;
295 if (uri
.endsWith(".css")) {
296 mimeType
= "text/css";
297 } else if (uri
.endsWith(".html")) {
298 mimeType
= "text/html";
299 } else if (uri
.endsWith(".js")) {
300 mimeType
= "text/javascript";
302 rep
= newChunkedResponse(Status
.OK
, mimeType
,
305 getTraceHandler().trace("404: " + uri
);
310 rep
= newFixedLengthResponse(Status
.NOT_FOUND
,
311 NanoHTTPD
.MIME_PLAINTEXT
, "Not Found");
313 } catch (Exception e
) {
314 Instance
.getInstance().getTraceHandler().error(
315 new IOException("Cannot process web request",
317 rep
= newFixedLengthResponse(Status
.INTERNAL_ERROR
,
318 NanoHTTPD
.MIME_PLAINTEXT
, "An error occured");
327 getTraceHandler().trace("Install SSL on the web server...");
328 server
.makeSecure(ssf
, null);
329 getTraceHandler().trace("Done.");
336 server
.start(NanoHTTPD
.SOCKET_READ_TIMEOUT
, false);
337 } catch (IOException e
) {
338 tracer
.error(new IOException("Cannot start the web server", e
));
343 * Start the server (listen on the network for new connections).
345 * Can only be called once.
347 * This call is asynchronous, and will just start a new {@link Thread} on
348 * itself (see {@link WebLibraryServer#run()}).
350 public void start() {
351 new Thread(this).start();
355 * The traces handler for this {@link WebLibraryServer}.
357 * @return the traces handler
359 public TraceHandler
getTraceHandler() {
364 * The traces handler for this {@link WebLibraryServer}.
367 * the new traces handler
369 public void setTraceHandler(TraceHandler tracer
) {
370 if (tracer
== null) {
371 tracer
= new TraceHandler(false, false, false);
374 this.tracer
= tracer
;
377 private LoginResult
login(String who
, String token
) {
378 List
<String
> subkeys
= Instance
.getInstance().getConfig().getList(
379 Config
.SERVER_ALLOWED_SUBKEYS
, new ArrayList
<String
>());
380 String realKey
= Instance
.getInstance().getConfig()
381 .getString(Config
.SERVER_KEY
, "");
383 return new LoginResult(token
, who
, realKey
, subkeys
);
387 private LoginResult
login(String who
, String key
, String subkey
) {
388 String realKey
= Instance
.getInstance().getConfig()
389 .getString(Config
.SERVER_KEY
, "");
391 // I don't like NULLs...
392 key
= key
== null ?
"" : key
;
393 subkey
= subkey
== null ?
"" : subkey
;
395 if (!realKey
.equals(key
)) {
396 return new LoginResult(null, null, null, false, false, false,
400 // defaults are true (as previous versions without the feature)
405 rw
= Instance
.getInstance().getConfig().getBoolean(Config
.SERVER_RW
,
407 if (!subkey
.isEmpty()) {
408 List
<String
> allowed
= Instance
.getInstance().getConfig()
409 .getList(Config
.SERVER_ALLOWED_SUBKEYS
);
410 if (allowed
!= null && allowed
.contains(subkey
)) {
411 if ((subkey
+ "|").contains("|rw|")) {
414 if ((subkey
+ "|").contains("|wl|")) {
415 wl
= false; // |wl| = bypass whitelist
417 if ((subkey
+ "|").contains("|bl|")) {
418 bl
= false; // |bl| = bypass blacklist
421 return new LoginResult(null, null, null, false, false, false,
426 return new LoginResult(who
, key
, subkey
, true, rw
, wl
, bl
);
429 private Response
loginPage(LoginResult login
, String uri
) {
430 StringBuilder builder
= new StringBuilder();
432 appendPreHtml(builder
, true);
434 if (login
.isBadLogin()) {
435 builder
.append("<div class='error'>Bad login or password</div>");
436 } else if (login
.isBadToken()) {
437 builder
.append("<div class='error'>Your session timed out</div>");
440 if (uri
.equals("/logout")) {
445 "<form method='POST' action='" + uri
+ "' class='login'>\n");
447 "<p>You must be logged into the system to see the stories.</p>");
448 builder
.append("\t<input type='text' name='login' />\n");
449 builder
.append("\t<input type='password' name='password' />\n");
450 builder
.append("\t<input type='submit' value='Login' />\n");
451 builder
.append("</form>\n");
453 appendPostHtml(builder
);
455 return NanoHTTPD
.newFixedLengthResponse(Status
.FORBIDDEN
,
456 NanoHTTPD
.MIME_HTML
, builder
.toString());
459 protected Response
getList(String uri
, LoginResult login
)
461 if (uri
.equals("/list/luids")) {
462 List
<JSONObject
> jsons
= new ArrayList
<JSONObject
>();
463 for (MetaData meta
: metas(login
)) {
464 jsons
.add(JsonIO
.toJson(meta
));
467 return newInputStreamResponse("application/json",
468 new ByteArrayInputStream(
469 new JSONArray(jsons
).toString().getBytes()));
472 return NanoHTTPD
.newFixedLengthResponse(Status
.BAD_REQUEST
,
473 NanoHTTPD
.MIME_PLAINTEXT
, null);
476 private Response
root(IHTTPSession session
, Map
<String
, String
> cookies
,
477 LoginResult login
) throws IOException
{
478 BasicLibrary lib
= Instance
.getInstance().getLibrary();
479 MetaResultList result
= new MetaResultList(metas(login
));
480 StringBuilder builder
= new StringBuilder();
482 appendPreHtml(builder
, true);
484 Map
<String
, String
> params
= session
.getParms();
486 String filter
= cookies
.get("filter");
487 if (params
.get("optionNo") != null)
489 if (filter
== null) {
493 String browser
= params
.get("browser") == null ?
""
494 : params
.get("browser");
495 String browser2
= params
.get("browser2") == null ?
""
496 : params
.get("browser2");
497 String browser3
= params
.get("browser3") == null ?
""
498 : params
.get("browser3");
500 String filterSource
= null;
501 String filterAuthor
= null;
502 String filterTag
= null;
504 // TODO: javascript in realtime, using visible=false + hide [submit]
506 builder
.append("<form class='browser'>\n");
507 builder
.append("<div class='breadcrumbs'>\n");
509 builder
.append("\t<select name='browser'>");
510 appendOption(builder
, 2, "", "", browser
);
511 appendOption(builder
, 2, "Sources", "sources", browser
);
512 appendOption(builder
, 2, "Authors", "authors", browser
);
513 appendOption(builder
, 2, "Tags", "tags", browser
);
514 builder
.append("\t</select>\n");
516 if (!browser
.isEmpty()) {
517 builder
.append("\t<select name='browser2'>");
518 if (browser
.equals("sources")) {
519 filterSource
= browser2
.isEmpty() ? filterSource
: browser2
;
520 // TODO: if 1 group -> no group
521 appendOption(builder
, 2, "", "", browser2
);
522 Map
<String
, List
<String
>> sources
= result
.getSourcesGrouped();
523 for (String source
: sources
.keySet()) {
524 appendOption(builder
, 2, source
, source
, browser2
);
526 } else if (browser
.equals("authors")) {
527 filterAuthor
= browser2
.isEmpty() ? filterAuthor
: browser2
;
528 // TODO: if 1 group -> no group
529 appendOption(builder
, 2, "", "", browser2
);
530 Map
<String
, List
<String
>> authors
= result
.getAuthorsGrouped();
531 for (String author
: authors
.keySet()) {
532 appendOption(builder
, 2, author
, author
, browser2
);
534 } else if (browser
.equals("tags")) {
535 filterTag
= browser2
.isEmpty() ? filterTag
: browser2
;
536 appendOption(builder
, 2, "", "", browser2
);
537 for (String tag
: result
.getTags()) {
538 appendOption(builder
, 2, tag
, tag
, browser2
);
541 builder
.append("\t</select>\n");
544 if (!browser2
.isEmpty()) {
545 if (browser
.equals("sources")) {
546 filterSource
= browser3
.isEmpty() ? filterSource
: browser3
;
547 Map
<String
, List
<String
>> sourcesGrouped
= result
548 .getSourcesGrouped();
549 List
<String
> sources
= sourcesGrouped
.get(browser2
);
550 if (sources
!= null && !sources
.isEmpty()) {
551 // TODO: single empty value
552 builder
.append("\t<select name='browser3'>");
553 appendOption(builder
, 2, "", "", browser3
);
554 for (String source
: sources
) {
555 appendOption(builder
, 2, source
, source
, browser3
);
557 builder
.append("\t</select>\n");
559 } else if (browser
.equals("authors")) {
560 filterAuthor
= browser3
.isEmpty() ? filterAuthor
: browser3
;
561 Map
<String
, List
<String
>> authorsGrouped
= result
562 .getAuthorsGrouped();
563 List
<String
> authors
= authorsGrouped
.get(browser2
);
564 if (authors
!= null && !authors
.isEmpty()) {
565 // TODO: single empty value
566 builder
.append("\t<select name='browser3'>");
567 appendOption(builder
, 2, "", "", browser3
);
568 for (String author
: authors
) {
569 appendOption(builder
, 2, author
, author
, browser3
);
571 builder
.append("\t</select>\n");
576 builder
.append("\t<input type='submit' value='Select'/>\n");
577 builder
.append("</div>\n");
579 // TODO: javascript in realtime, using visible=false + hide [submit]
580 builder
.append("<div class='filter'>\n");
581 builder
.append("\t<span class='label'>Filter: </span>\n");
583 "\t<input name='optionName' type='hidden' value='filter' />\n");
584 builder
.append("\t<input name='optionValue' type='text' value='"
585 + filter
+ "' place-holder='...' />\n");
586 builder
.append("\t<input name='optionNo' type='submit' value='x' />");
588 "\t<input name='submit' type='submit' value='Filter' />\n");
589 builder
.append("</div>\n");
590 builder
.append("</form>\n");
592 builder
.append("\t<div class='books'>");
593 for (MetaData meta
: result
.getMetas()) {
594 if (!filter
.isEmpty() && !meta
.getTitle().toLowerCase()
595 .contains(filter
.toLowerCase())) {
600 if (filterSource
!= null
601 && !filterSource
.equals(meta
.getSource())) {
606 if (filterAuthor
!= null
607 && !filterAuthor
.equals(meta
.getAuthor())) {
611 if (filterTag
!= null && !meta
.getTags().contains(filterTag
)) {
615 builder
.append("<div class='book_line'>");
616 builder
.append("<a href='");
617 builder
.append(getViewUrl(meta
.getLuid(), null, null));
619 builder
.append(" class='link'>");
621 if (lib
.isCached(meta
.getLuid())) {
624 "<span class='cache_icon cached'>◉</span>");
628 "<span class='cache_icon uncached'>○</span>");
630 builder
.append("<span class='luid'>");
631 builder
.append(meta
.getLuid());
632 builder
.append("</span>");
633 builder
.append("<span class='title'>");
634 builder
.append(meta
.getTitle());
635 builder
.append("</span>");
636 builder
.append("<span class='author'>");
637 if (meta
.getAuthor() != null && !meta
.getAuthor().isEmpty()) {
638 builder
.append("(").append(meta
.getAuthor()).append(")");
640 builder
.append("</span>");
641 builder
.append("</a></div>\n");
643 builder
.append("</div>");
645 appendPostHtml(builder
);
646 return NanoHTTPD
.newFixedLengthResponse(builder
.toString());
649 // /story/luid/chapter/para <-- text/image
650 // /story/luid/cover <-- image
651 // /story/luid/metadata <-- json
652 // /story/luid/json <-- json, whole chapter (no images)
653 private Response
getStoryPart(String uri
, LoginResult login
) {
654 String
[] cover
= uri
.split("/");
657 if (cover
.length
< off
+ 2) {
658 return NanoHTTPD
.newFixedLengthResponse(Status
.BAD_REQUEST
,
659 NanoHTTPD
.MIME_PLAINTEXT
, null);
662 String luid
= cover
[off
+ 0];
663 String chapterStr
= cover
[off
+ 1];
664 String imageStr
= cover
.length
< off
+ 3 ?
null : cover
[off
+ 2];
666 // 1-based (0 = desc)
668 if (chapterStr
!= null && !"cover".equals(chapterStr
)
669 && !"metadata".equals(chapterStr
)
670 && !"json".equals(chapterStr
)) {
672 chapter
= Integer
.parseInt(chapterStr
);
674 throw new NumberFormatException();
676 } catch (NumberFormatException e
) {
677 return NanoHTTPD
.newFixedLengthResponse(Status
.BAD_REQUEST
,
678 NanoHTTPD
.MIME_PLAINTEXT
, "Chapter is not valid");
684 if (imageStr
!= null) {
686 paragraph
= Integer
.parseInt(imageStr
);
688 throw new NumberFormatException();
690 } catch (NumberFormatException e
) {
691 return NanoHTTPD
.newFixedLengthResponse(Status
.BAD_REQUEST
,
692 NanoHTTPD
.MIME_PLAINTEXT
, "Paragraph is not valid");
696 String mimeType
= NanoHTTPD
.MIME_PLAINTEXT
;
697 InputStream in
= null;
699 if ("cover".equals(chapterStr
)) {
700 Image img
= getCover(luid
, login
);
702 in
= img
.newInputStream();
704 // TODO: get correct image type
705 mimeType
= "image/png";
706 } else if ("metadata".equals(chapterStr
)) {
707 MetaData meta
= meta(luid
, login
);
708 JSONObject json
= JsonIO
.toJson(meta
);
709 mimeType
= "application/json";
710 in
= new ByteArrayInputStream(json
.toString().getBytes());
711 } else if ("json".equals(chapterStr
)) {
712 Story story
= story(luid
, login
);
713 JSONObject json
= JsonIO
.toJson(story
);
714 mimeType
= "application/json";
715 in
= new ByteArrayInputStream(json
.toString().getBytes());
717 Story story
= story(luid
, login
);
720 StringBuilder builder
= new StringBuilder();
721 for (Paragraph p
: story
.getMeta().getResume()) {
722 if (builder
.length() == 0) {
723 builder
.append("\n");
725 builder
.append(p
.getContent());
728 in
= new ByteArrayInputStream(
729 builder
.toString().getBytes("utf-8"));
731 Paragraph para
= story
.getChapters().get(chapter
- 1)
732 .getParagraphs().get(paragraph
- 1);
733 Image img
= para
.getContentImage();
734 if (para
.getType() == ParagraphType
.IMAGE
) {
735 // TODO: get correct image type
736 mimeType
= "image/png";
737 in
= img
.newInputStream();
739 in
= new ByteArrayInputStream(
740 para
.getContent().getBytes("utf-8"));
745 } catch (IndexOutOfBoundsException e
) {
746 return NanoHTTPD
.newFixedLengthResponse(Status
.NOT_FOUND
,
747 NanoHTTPD
.MIME_PLAINTEXT
,
748 "Chapter or paragraph does not exist");
749 } catch (IOException e
) {
750 Instance
.getInstance().getTraceHandler()
751 .error(new IOException("Cannot get image: " + uri
, e
));
752 return NanoHTTPD
.newFixedLengthResponse(Status
.INTERNAL_ERROR
,
753 NanoHTTPD
.MIME_PLAINTEXT
, "Error when processing request");
756 return newInputStreamResponse(mimeType
, in
);
759 private Response
getViewer(Map
<String
, String
> cookies
, String uri
,
761 String
[] cover
= uri
.split("/");
764 if (cover
.length
< off
+ 2) {
765 return NanoHTTPD
.newFixedLengthResponse(Status
.BAD_REQUEST
,
766 NanoHTTPD
.MIME_PLAINTEXT
, null);
769 String type
= cover
[off
+ 0];
770 String luid
= cover
[off
+ 1];
771 String chapterStr
= cover
.length
< off
+ 3 ?
null : cover
[off
+ 2];
772 String paragraphStr
= cover
.length
< off
+ 4 ?
null : cover
[off
+ 3];
774 // 1-based (0 = desc)
776 if (chapterStr
!= null) {
778 chapter
= Integer
.parseInt(chapterStr
);
780 throw new NumberFormatException();
782 } catch (NumberFormatException e
) {
783 return NanoHTTPD
.newFixedLengthResponse(Status
.BAD_REQUEST
,
784 NanoHTTPD
.MIME_PLAINTEXT
, "Chapter is not valid");
790 if (paragraphStr
!= null) {
792 paragraph
= Integer
.parseInt(paragraphStr
);
793 if (paragraph
<= 0) {
794 throw new NumberFormatException();
796 } catch (NumberFormatException e
) {
797 return NanoHTTPD
.newFixedLengthResponse(Status
.BAD_REQUEST
,
798 NanoHTTPD
.MIME_PLAINTEXT
, "Paragraph is not valid");
803 Story story
= story(luid
, login
);
805 return NanoHTTPD
.newFixedLengthResponse(Status
.NOT_FOUND
,
806 NanoHTTPD
.MIME_PLAINTEXT
, "Story not found");
809 StringBuilder builder
= new StringBuilder();
810 appendPreHtml(builder
, false);
812 // For images documents, always go to the images if not chap 0 desc
813 if (story
.getMeta().isImageDocument()) {
814 if (chapter
> 0 && paragraph
<= 0)
820 chap
= story
.getMeta().getResume();
823 chap
= story
.getChapters().get(chapter
- 1);
824 } catch (IndexOutOfBoundsException e
) {
825 return NanoHTTPD
.newFixedLengthResponse(Status
.NOT_FOUND
,
826 NanoHTTPD
.MIME_PLAINTEXT
, "Chapter not found");
830 String first
, previous
, next
, last
;
832 StringBuilder content
= new StringBuilder();
834 String disabledLeft
= "";
835 String disabledRight
= "";
836 String disabledZoomReal
= "";
837 String disabledZoomWidth
= "";
838 String disabledZoomHeight
= "";
840 if (paragraph
<= 0) {
841 first
= getViewUrl(luid
, 0, null);
842 previous
= getViewUrl(luid
, (Math
.max(chapter
- 1, 0)), null);
843 next
= getViewUrl(luid
,
844 (Math
.min(chapter
+ 1, story
.getChapters().size())),
846 last
= getViewUrl(luid
, story
.getChapters().size(), null);
848 StringBuilder desc
= new StringBuilder();
851 desc
.append("<h1 class='title'>");
852 desc
.append(story
.getMeta().getTitle());
853 desc
.append("</h1>\n");
854 desc
.append("<div class='desc'>\n");
855 desc
.append("\t<a href='" + next
+ "' class='cover'>\n");
856 desc
.append("\t\t<img src='/story/" + luid
+ "/cover'/>\n");
857 desc
.append("\t</a>\n");
858 desc
.append("\t<table class='details'>\n");
859 Map
<String
, String
> details
= BasicLibrary
860 .getMetaDesc(story
.getMeta());
861 for (String key
: details
.keySet()) {
862 appendTableRow(desc
, 2, key
, details
.get(key
));
864 desc
.append("\t</table>\n");
865 desc
.append("</div>\n");
866 desc
.append("<h1 class='title'>Description</h1>\n");
869 content
.append("<div class='viewer text'>\n");
870 content
.append(desc
);
871 String description
= new TextOutput(false).convert(chap
,
873 content
.append(chap
.getParagraphs().size() <= 0
874 ?
"No content provided."
876 content
.append("</div>\n");
879 disabledLeft
= " disabled='disbaled'";
880 if (chapter
>= story
.getChapters().size())
881 disabledRight
= " disabled='disbaled'";
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());
891 disabledLeft
= " disabled='disbaled'";
892 if (paragraph
>= chap
.getParagraphs().size())
893 disabledRight
= " disabled='disbaled'";
895 // First -> previous *chapter*
898 first
= getViewUrl(luid
, (Math
.max(chapter
- 1, 0)), null);
899 if (paragraph
<= 1) {
903 Paragraph para
= null;
905 para
= chap
.getParagraphs().get(paragraph
- 1);
906 } catch (IndexOutOfBoundsException e
) {
907 return NanoHTTPD
.newFixedLengthResponse(Status
.NOT_FOUND
,
908 NanoHTTPD
.MIME_PLAINTEXT
,
909 "Paragraph " + paragraph
+ " not found");
912 if (para
.getType() == ParagraphType
.IMAGE
) {
913 String zoomStyle
= "max-width: 100%;";
914 disabledZoomWidth
= " disabled='disabled'";
915 String zoomOption
= cookies
.get("zoom");
916 if (zoomOption
!= null && !zoomOption
.isEmpty()) {
917 if (zoomOption
.equals("real")) {
919 disabledZoomWidth
= "";
920 disabledZoomReal
= " disabled='disabled'";
921 } else if (zoomOption
.equals("width")) {
922 zoomStyle
= "max-width: 100%;";
923 } else if (zoomOption
.equals("height")) {
924 // see height of navbar + optionbar
925 zoomStyle
= "max-height: calc(100% - 128px);";
926 disabledZoomWidth
= "";
927 disabledZoomHeight
= " disabled='disabled'";
931 String javascript
= "document.getElementById(\"previous\").click(); return false;";
932 content
.append(String
.format("" //
933 + "<a class='viewer link' oncontextmenu='%s' href='%s'>"
934 + "<img class='viewer img' style='%s' src='%s'/>"
939 getStoryUrl(luid
, chapter
, paragraph
)));
941 content
.append(String
.format("" //
942 + "<div class='viewer text'>%s</div>", //
947 builder
.append(String
.format("" //
948 + "<div class='bar navbar'>\n" //
949 + "\t<a%s class='button first' href='%s'><<</a>\n"//
950 + "\t<a%s id='previous' class='button previous' href='%s'><</a>\n" //
951 + "\t<div class='gotobox itemsbox'>\n" //
952 + "\t\t<div class='button goto'>%d</div>\n" //
953 + "\t\t<div class='items goto'>\n", //
954 disabledLeft
, first
, //
955 disabledLeft
, previous
, //
956 paragraph
> 0 ? paragraph
: chapter
//
959 // List of chap/para links
961 appendItemA(builder
, 3, getViewUrl(luid
, 0, null), "Description",
962 paragraph
== 0 && chapter
== 0);
964 for (int i
= 1; i
<= chap
.getParagraphs().size(); i
++) {
965 appendItemA(builder
, 3, getViewUrl(luid
, chapter
, i
),
966 "Image " + i
, paragraph
== i
);
970 for (Chapter c
: story
.getChapters()) {
971 String chapName
= "Chapter " + c
.getNumber();
972 if (c
.getName() != null && !c
.getName().isEmpty()) {
973 chapName
+= ": " + c
.getName();
976 appendItemA(builder
, 3, getViewUrl(luid
, i
, null), chapName
,
983 builder
.append(String
.format("" //
986 + "\t<a%s class='button next' href='%s'>></a>\n" //
987 + "\t<a%s class='button last' href='%s'>>></a>\n"//
989 disabledRight
, next
, //
990 disabledRight
, last
//
993 builder
.append(content
);
995 builder
.append("<div class='bar optionbar ");
997 builder
.append("s4");
999 builder
.append("s1");
1001 builder
.append("'>\n");
1002 builder
.append(" <a class='button back' href='/'>BACK</a>\n");
1004 if (paragraph
> 0) {
1005 builder
.append(String
.format("" //
1006 + "\t<a%s class='button zoomreal' href='%s'>REAL</a>\n"//
1007 + "\t<a%s class='button zoomwidth' href='%s'>WIDTH</a>\n"//
1008 + "\t<a%s class='button zoomheight' href='%s'>HEIGHT</a>\n"//
1011 uri
+ "?optionName=zoom&optionValue=real", //
1013 uri
+ "?optionName=zoom&optionValue=width", //
1015 uri
+ "?optionName=zoom&optionValue=height" //
1019 appendPostHtml(builder
);
1020 return NanoHTTPD
.newFixedLengthResponse(Status
.OK
,
1021 NanoHTTPD
.MIME_HTML
, builder
.toString());
1022 } catch (IOException e
) {
1023 Instance
.getInstance().getTraceHandler()
1024 .error(new IOException("Cannot get image: " + uri
, e
));
1025 return NanoHTTPD
.newFixedLengthResponse(Status
.INTERNAL_ERROR
,
1026 NanoHTTPD
.MIME_PLAINTEXT
, "Error when processing request");
1030 private Response
newInputStreamResponse(String mimeType
, InputStream in
) {
1032 return NanoHTTPD
.newFixedLengthResponse(Status
.NO_CONTENT
, "",
1035 return NanoHTTPD
.newChunkedResponse(Status
.OK
, mimeType
, in
);
1038 private String
getContentOf(String file
) {
1039 InputStream in
= IOUtils
.openResource(WebLibraryServerIndex
.class,
1043 return IOUtils
.readSmallStream(in
);
1044 } catch (IOException e
) {
1045 Instance
.getInstance().getTraceHandler().error(
1046 new IOException("Cannot get file: index.pre.html", e
));
1053 private String
getViewUrl(String luid
, Integer chap
, Integer para
) {
1054 return VIEWER_URL
//
1055 .replace("{luid}", luid
) //
1056 .replace("/{chap}", chap
== null ?
"" : "/" + chap
) //
1058 (chap
== null || para
== null) ?
"" : "/" + para
);
1061 private String
getStoryUrl(String luid
, int chap
, Integer para
) {
1063 .replace("{luid}", luid
) //
1064 .replace("{chap}", Integer
.toString(chap
)) //
1065 .replace("{para}", para
== null ?
"" : Integer
.toString(para
));
1068 private String
getStoryUrlCover(String luid
) {
1069 return STORY_URL_COVER
//
1070 .replace("{luid}", luid
);
1073 private boolean isAllowed(MetaData meta
, LoginResult login
) {
1074 if (login
.isWl() && !whitelist
.isEmpty()
1075 && !whitelist
.contains(meta
.getSource())) {
1078 if (login
.isBl() && blacklist
.contains(meta
.getSource())) {
1085 private List
<MetaData
> metas(LoginResult login
) throws IOException
{
1086 BasicLibrary lib
= Instance
.getInstance().getLibrary();
1087 System
.out
.println("Whitelist: " + whitelist
);
1088 System
.out
.println("Blacklist: " + blacklist
);
1089 System
.out
.println("isWl: " + login
.isWl());
1090 System
.out
.println("isBl: " + login
.isBl());
1092 List
<MetaData
> metas
= new ArrayList
<MetaData
>();
1093 for (MetaData meta
: lib
.getList().getMetas()) {
1094 if (isAllowed(meta
, login
)) {
1102 private MetaData
meta(String luid
, LoginResult login
) throws IOException
{
1103 BasicLibrary lib
= Instance
.getInstance().getLibrary();
1104 MetaData meta
= lib
.getInfo(luid
);
1105 if (!isAllowed(meta
, login
))
1111 private Image
getCover(String luid
, LoginResult login
) throws IOException
{
1112 MetaData meta
= meta(luid
, login
);
1114 BasicLibrary lib
= Instance
.getInstance().getLibrary();
1115 return lib
.getCover(meta
.getLuid());
1121 // NULL if not whitelist OK or if not found
1122 private Story
story(String luid
, LoginResult login
) throws IOException
{
1123 synchronized (storyCache
) {
1124 if (storyCache
.containsKey(luid
)) {
1125 Story story
= storyCache
.get(luid
);
1126 if (!isAllowed(story
.getMeta(), login
))
1134 MetaData meta
= meta(luid
, login
);
1136 BasicLibrary lib
= Instance
.getInstance().getLibrary();
1137 story
= lib
.getStory(luid
, null);
1138 long size
= sizeOf(story
);
1140 synchronized (storyCache
) {
1141 // Could have been added by another request
1142 if (!storyCache
.containsKey(luid
)) {
1143 while (!storyCacheOrder
.isEmpty()
1144 && storyCacheSize
+ size
> maxStoryCacheSize
) {
1145 String oldestLuid
= storyCacheOrder
.removeFirst();
1146 Story oldestStory
= storyCache
.remove(oldestLuid
);
1147 maxStoryCacheSize
-= sizeOf(oldestStory
);
1150 storyCacheOrder
.add(luid
);
1151 storyCache
.put(luid
, story
);
1159 private long sizeOf(Story story
) {
1161 for (Chapter chap
: story
) {
1162 for (Paragraph para
: chap
) {
1163 if (para
.getType() == ParagraphType
.IMAGE
) {
1164 size
+= para
.getContentImage().getSize();
1166 size
+= para
.getContent().length();
1174 private void appendPreHtml(StringBuilder builder
, boolean banner
) {
1175 String favicon
= "favicon.ico";
1176 String icon
= Instance
.getInstance().getUiConfig()
1177 .getString(UiConfig
.PROGRAM_ICON
);
1179 favicon
= "icon_" + icon
.replace("-", "_") + ".png";
1183 getContentOf("index.pre.html").replace("favicon.ico", favicon
));
1186 builder
.append("<div class='banner'>\n");
1187 builder
.append("\t<img class='ico' src='/") //
1190 builder
.append("\t<h1>Fanfix</h1>\n");
1191 builder
.append("\t<h2>") //
1192 .append(Version
.getCurrentVersion()) //
1194 builder
.append("</div>\n");
1198 private void appendPostHtml(StringBuilder builder
) {
1199 builder
.append(getContentOf("index.post.html"));
1202 private void appendOption(StringBuilder builder
, int depth
, String name
,
1203 String value
, String selected
) {
1204 for (int i
= 0; i
< depth
; i
++) {
1205 builder
.append("\t");
1207 builder
.append("<option value='").append(value
).append("'");
1208 if (value
.equals(selected
)) {
1209 builder
.append(" selected='selected'");
1211 builder
.append(">").append(name
).append("</option>\n");
1214 private void appendTableRow(StringBuilder builder
, int depth
,
1216 for (int i
= 0; i
< depth
; i
++) {
1217 builder
.append("\t");
1221 builder
.append("<tr>");
1222 for (String td
: tds
) {
1223 builder
.append("<td class='col");
1224 builder
.append(col
++);
1225 builder
.append("'>");
1227 builder
.append("</td>");
1229 builder
.append("</tr>\n");
1232 private void appendItemA(StringBuilder builder
, int depth
, String link
,
1233 String name
, boolean selected
) {
1234 for (int i
= 0; i
< depth
; i
++) {
1235 builder
.append("\t");
1238 builder
.append("<a href='");
1239 builder
.append(link
);
1240 builder
.append("' class='item goto");
1242 builder
.append(" selected");
1244 builder
.append("'>");
1245 builder
.append(name
);
1246 builder
.append("</a>\n");
1249 public static void main(String
[] args
) throws IOException
{
1251 WebLibraryServer web
= new WebLibraryServer(false);