50dc3d942dcf39aeeadeb68d88c58a714e7551cf
[fanfix.git] / src / be / nikiroo / fanfix / library / WebLibraryServer.java
1 package be.nikiroo.fanfix.library;
2
3 import java.io.ByteArrayInputStream;
4 import java.io.IOException;
5 import java.io.InputStream;
6 import java.net.URL;
7 import java.util.ArrayList;
8 import java.util.Arrays;
9 import java.util.HashMap;
10 import java.util.LinkedList;
11 import java.util.List;
12 import java.util.Map;
13
14 import org.json.JSONArray;
15 import org.json.JSONObject;
16
17 import be.nikiroo.fanfix.Instance;
18 import be.nikiroo.fanfix.bundles.Config;
19 import be.nikiroo.fanfix.data.Chapter;
20 import be.nikiroo.fanfix.data.JsonIO;
21 import be.nikiroo.fanfix.data.MetaData;
22 import be.nikiroo.fanfix.data.Paragraph;
23 import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
24 import be.nikiroo.fanfix.data.Story;
25 import be.nikiroo.utils.Image;
26 import be.nikiroo.utils.LoginResult;
27 import be.nikiroo.utils.NanoHTTPD;
28 import be.nikiroo.utils.NanoHTTPD.Response;
29 import be.nikiroo.utils.NanoHTTPD.Response.Status;
30 import be.nikiroo.utils.Progress;
31
32 public class WebLibraryServer extends WebLibraryServerHtml {
33 class WLoginResult extends LoginResult {
34 public WLoginResult(boolean badLogin, boolean badCookie) {
35 super(badLogin, badCookie);
36 }
37
38 public WLoginResult(String who, String key, String subkey, boolean rw,
39 boolean wl, boolean bl) {
40 super(who, key, subkey, (rw ? "|rw" : "") + (wl ? "|wl" : "")
41 + (bl ? "|bl" : "") + "|");
42 }
43
44 public WLoginResult(String cookie, String who, String key,
45 List<String> subkeys) {
46 super(cookie, who, key, subkeys,
47 subkeys == null || subkeys.isEmpty());
48 }
49
50 public boolean isRw() {
51 return getOption().contains("|rw|");
52 }
53
54 public boolean isWl() {
55 return getOption().contains("|wl|");
56 }
57
58 public boolean isBl() {
59 return getOption().contains("|bl|");
60 }
61 }
62
63 private Map<String, Story> storyCache = new HashMap<String, Story>();
64 private LinkedList<String> storyCacheOrder = new LinkedList<String>();
65 private long storyCacheSize = 0;
66 private long maxStoryCacheSize;
67
68 private List<String> whitelist;
69 private List<String> blacklist;
70
71 private Map<String, Progress> imprts = new HashMap<String, Progress>();
72
73 private boolean exiting;
74
75 public WebLibraryServer(boolean secure) throws IOException {
76 super(secure);
77
78 int cacheMb = Instance.getInstance().getConfig()
79 .getInteger(Config.SERVER_MAX_CACHE_MB, 100);
80 maxStoryCacheSize = cacheMb * 1024 * 1024;
81
82 setTraceHandler(Instance.getInstance().getTraceHandler());
83
84 whitelist = Instance.getInstance().getConfig()
85 .getList(Config.SERVER_WHITELIST, new ArrayList<String>());
86 blacklist = Instance.getInstance().getConfig()
87 .getList(Config.SERVER_BLACKLIST, new ArrayList<String>());
88 }
89
90 /**
91 * Start the server (listen on the network for new connections).
92 * <p>
93 * Can only be called once.
94 * <p>
95 * This call is asynchronous, and will just start a new {@link Thread} on
96 * itself (see {@link WebLibraryServer#run()}).
97 */
98 public void start() {
99 new Thread(this).start();
100 }
101
102 @Override
103 protected Response stop(WLoginResult login) {
104 if (!login.isRw()) {
105 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
106 NanoHTTPD.MIME_PLAINTEXT, "Exit not allowed");
107 }
108
109 if (exiting) {
110 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
111 NanoHTTPD.MIME_PLAINTEXT, "Server is already exiting...");
112 }
113
114 exiting = true;
115 Instance.getInstance().getTraceHandler().trace("Exiting");
116
117 boolean ok;
118 do {
119 synchronized (imprts) {
120 ok = imprts.isEmpty();
121 }
122 if (!ok) {
123 try {
124 Thread.sleep(2000);
125 } catch (InterruptedException e) {
126 Instance.getInstance().getTraceHandler()
127 .trace("Waiting to exit...");
128 }
129 }
130 } while (!ok);
131
132 doStop();
133
134 return NanoHTTPD.newFixedLengthResponse(Status.OK,
135 NanoHTTPD.MIME_PLAINTEXT, "Exited");
136 }
137
138 @Override
139 protected WLoginResult login(boolean badLogin, boolean badCookie) {
140 return new WLoginResult(false, false);
141 }
142
143 @Override
144 protected WLoginResult login(String who, String cookie) {
145 List<String> subkeys = Instance.getInstance().getConfig()
146 .getList(Config.SERVER_ALLOWED_SUBKEYS);
147 String realKey = Instance.getInstance().getConfig()
148 .getString(Config.SERVER_KEY);
149
150 return new WLoginResult(cookie, who, realKey, subkeys);
151 }
152
153 // allow rw/wl
154 @Override
155 protected WLoginResult login(String who, String key, String subkey) {
156 String realKey = Instance.getInstance().getConfig()
157 .getString(Config.SERVER_KEY, "");
158
159 // I don't like NULLs...
160 key = key == null ? "" : key;
161 subkey = subkey == null ? "" : subkey;
162
163 if (!realKey.equals(key)) {
164 return new WLoginResult(true, false);
165 }
166
167 // defaults are true (as previous versions without the feature)
168 boolean rw = true;
169 boolean wl = true;
170 boolean bl = true;
171
172 rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
173 rw);
174
175 List<String> allowed = Instance.getInstance().getConfig().getList(
176 Config.SERVER_ALLOWED_SUBKEYS, new ArrayList<String>());
177
178 if (!allowed.isEmpty()) {
179 if (!allowed.contains(subkey)) {
180 return new WLoginResult(true, false);
181 }
182
183 if ((subkey + "|").contains("|rw|")) {
184 rw = true;
185 }
186 if ((subkey + "|").contains("|wl|")) {
187 wl = false; // |wl| = bypass whitelist
188 }
189 if ((subkey + "|").contains("|bl|")) {
190 bl = false; // |bl| = bypass blacklist
191 }
192 }
193
194 return new WLoginResult(who, key, subkey, rw, wl, bl);
195 }
196
197 @Override
198 protected Response getList(String uri, WLoginResult login)
199 throws IOException {
200 if (WebLibraryUrls.LIST_URL_METADATA.equals(uri)) {
201 List<JSONObject> jsons = new ArrayList<JSONObject>();
202 for (MetaData meta : metas(login)) {
203 jsons.add(JsonIO.toJson(meta));
204 }
205
206 return newInputStreamResponse("application/json",
207 new ByteArrayInputStream(
208 new JSONArray(jsons).toString().getBytes()));
209 }
210
211 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
212 NanoHTTPD.MIME_PLAINTEXT, null);
213 }
214
215 // /story/luid/chapter/para <-- text/image
216 // /story/luid/cover <-- image
217 // /story/luid/metadata <-- json
218 // /story/luid/json <-- json, whole chapter (no images)
219 @Override
220 protected Response getStoryPart(String uri, WLoginResult login) {
221 String[] uriParts = uri.split("/");
222 int off = 2;
223
224 if (uriParts.length < off + 2) {
225 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
226 NanoHTTPD.MIME_PLAINTEXT, null);
227 }
228
229 String luid = uriParts[off + 0];
230 String chapterStr = uriParts[off + 1];
231 String imageStr = uriParts.length < off + 3 ? null : uriParts[off + 2];
232
233 // 1-based (0 = desc)
234 int chapter = 0;
235 if (chapterStr != null && !"cover".equals(chapterStr)
236 && !"metadata".equals(chapterStr)
237 && !"json".equals(chapterStr)) {
238 try {
239 chapter = Integer.parseInt(chapterStr);
240 if (chapter < 0) {
241 throw new NumberFormatException();
242 }
243 } catch (NumberFormatException e) {
244 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
245 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
246 }
247 }
248
249 // 1-based
250 int paragraph = 1;
251 if (imageStr != null) {
252 try {
253 paragraph = Integer.parseInt(imageStr);
254 if (paragraph < 0) {
255 throw new NumberFormatException();
256 }
257 } catch (NumberFormatException e) {
258 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
259 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
260 }
261 }
262
263 String mimeType = NanoHTTPD.MIME_PLAINTEXT;
264 InputStream in = null;
265 try {
266 if ("cover".equals(chapterStr)) {
267 Image img = storyCover(luid, login);
268 if (img != null) {
269 in = img.newInputStream();
270 }
271 // TODO: get correct image type
272 mimeType = "image/png";
273 } else if ("metadata".equals(chapterStr)) {
274 MetaData meta = meta(luid, login);
275 JSONObject json = JsonIO.toJson(meta);
276 mimeType = "application/json";
277 in = new ByteArrayInputStream(json.toString().getBytes());
278 } else if ("json".equals(chapterStr)) {
279 Story story = story(luid, login);
280 JSONObject json = JsonIO.toJson(story);
281 mimeType = "application/json";
282 in = new ByteArrayInputStream(json.toString().getBytes());
283 } else {
284 Story story = story(luid, login);
285 if (story != null) {
286 if (chapter == 0) {
287 StringBuilder builder = new StringBuilder();
288 for (Paragraph p : story.getMeta().getResume()) {
289 if (builder.length() == 0) {
290 builder.append("\n");
291 }
292 builder.append(p.getContent());
293 }
294
295 in = new ByteArrayInputStream(
296 builder.toString().getBytes("utf-8"));
297 } else {
298 Paragraph para = story.getChapters().get(chapter - 1)
299 .getParagraphs().get(paragraph - 1);
300 Image img = para.getContentImage();
301 if (para.getType() == ParagraphType.IMAGE) {
302 // TODO: get correct image type
303 mimeType = "image/png";
304 in = img.newInputStream();
305 } else {
306 in = new ByteArrayInputStream(
307 para.getContent().getBytes("utf-8"));
308 }
309 }
310 }
311 }
312 } catch (IndexOutOfBoundsException e) {
313 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
314 NanoHTTPD.MIME_PLAINTEXT,
315 "Chapter or paragraph does not exist");
316 } catch (IOException e) {
317 Instance.getInstance().getTraceHandler()
318 .error(new IOException("Cannot get image: " + uri, e));
319 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
320 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
321 }
322
323 return newInputStreamResponse(mimeType, in);
324 }
325
326 // /story/luid/source
327 // /story/luid/title
328 // /story/luid/author
329 @Override
330 protected Response setStoryPart(String uri, String value,
331 WLoginResult login) throws IOException {
332 String[] uriParts = uri.split("/");
333 int off = 2; // "" and "story"
334
335 if (uriParts.length < off + 2) {
336 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
337 NanoHTTPD.MIME_PLAINTEXT, "Invalid story part request");
338 }
339
340 if (!login.isRw()) {
341 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
342 NanoHTTPD.MIME_PLAINTEXT, "SET story part not allowed");
343 }
344
345 if (exiting) {
346 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
347 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
348 }
349
350 String luid = uriParts[off + 0];
351 String type = uriParts[off + 1];
352
353 if (!Arrays.asList("source", "title", "author").contains(type)) {
354 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
355 NanoHTTPD.MIME_PLAINTEXT,
356 "Invalid SET story part: " + type);
357 }
358
359 if (meta(luid, login) != null) {
360 BasicLibrary lib = Instance.getInstance().getLibrary();
361 if ("source".equals(type)) {
362 lib.changeSource(luid, value, null);
363 } else if ("title".equals(type)) {
364 lib.changeTitle(luid, value, null);
365 } else if ("author".equals(type)) {
366 lib.changeAuthor(luid, value, null);
367 }
368 }
369
370 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
371 }
372
373 @Override
374 protected Response getCover(String uri, WLoginResult login)
375 throws IOException {
376 String[] uriParts = uri.split("/");
377 int off = 2; // "" and "cover"
378
379 if (uriParts.length < off + 2) {
380 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
381 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
382 }
383
384 String type = uriParts[off + 0];
385 String id = uriParts[off + 1];
386
387 InputStream in = null;
388
389 if ("story".equals(type)) {
390 Image img = storyCover(id, login);
391 if (img != null) {
392 in = img.newInputStream();
393 }
394 } else if ("source".equals(type)) {
395 Image img = sourceCover(id, login);
396 if (img != null) {
397 in = img.newInputStream();
398 }
399 } else if ("author".equals(type)) {
400 Image img = authorCover(id, login);
401 if (img != null) {
402 in = img.newInputStream();
403 }
404 } else {
405 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
406 NanoHTTPD.MIME_PLAINTEXT,
407 "Invalid GET cover type: " + type);
408 }
409
410 // TODO: get correct image type
411 return newInputStreamResponse("image/png", in);
412 }
413
414 @Override
415 protected Response setCover(String uri, String luid, WLoginResult login)
416 throws IOException {
417 String[] uriParts = uri.split("/");
418 int off = 2; // "" and "cover"
419
420 if (uriParts.length < off + 2) {
421 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
422 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
423 }
424
425 if (!login.isRw()) {
426 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
427 NanoHTTPD.MIME_PLAINTEXT, "Cover request not allowed");
428 }
429
430 if (exiting) {
431 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
432 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
433 }
434
435 String type = uriParts[off + 0];
436 String id = uriParts[off + 1];
437
438 if ("source".equals(type)) {
439 sourceCover(id, login, luid);
440 } else if ("author".equals(type)) {
441 authorCover(id, login, luid);
442 } else {
443 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
444 NanoHTTPD.MIME_PLAINTEXT,
445 "Invalid SET cover type: " + type);
446 }
447
448 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
449 }
450
451 @Override
452 protected Response imprt(String uri, String urlStr, WLoginResult login)
453 throws IOException {
454 final BasicLibrary lib = Instance.getInstance().getLibrary();
455
456 if (!login.isRw()) {
457 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
458 NanoHTTPD.MIME_PLAINTEXT, "Import not allowed");
459 }
460
461 if (exiting) {
462 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
463 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
464 }
465
466 final URL url = new URL(urlStr);
467 final Progress pg = new Progress();
468 final String luid = lib.getNextId();
469
470 synchronized (imprts) {
471 imprts.put(luid, pg);
472 }
473
474 new Thread(new Runnable() {
475 @Override
476 public void run() {
477 try {
478 lib.imprt(url, luid, pg);
479 } catch (IOException e) {
480 Instance.getInstance().getTraceHandler().error(e);
481 } finally {
482 synchronized (imprts) {
483 imprts.remove(luid);
484 }
485 }
486 }
487 }, "Import story: " + urlStr).start();
488
489 return NanoHTTPD.newFixedLengthResponse(Status.OK,
490 NanoHTTPD.MIME_PLAINTEXT, luid);
491 }
492
493 @Override
494 protected Response imprtProgress(String uri, WLoginResult login) {
495 String[] uriParts = uri.split("/");
496 int off = 2; // "" and "import"
497
498 if (uriParts.length < off + 1) {
499 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
500 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
501 }
502
503 String luid = uriParts[off + 0];
504
505 Progress pg = null;
506 synchronized (imprts) {
507 pg = imprts.get(luid);
508 }
509 if (pg != null) {
510 return NanoHTTPD.newFixedLengthResponse(Status.OK,
511 "application/json", JsonIO.toJson(pg).toString());
512 }
513
514 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
515 }
516
517 @Override
518 protected Response delete(String uri, WLoginResult login)
519 throws IOException {
520 String[] uriParts = uri.split("/");
521 int off = 2; // "" and "delete"
522
523 if (uriParts.length < off + 1) {
524 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
525 NanoHTTPD.MIME_PLAINTEXT, "Invalid delete request");
526 }
527
528 if (!login.isRw()) {
529 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
530 NanoHTTPD.MIME_PLAINTEXT, "Delete not allowed");
531 }
532
533 if (exiting) {
534 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
535 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
536 }
537
538 String luid = uriParts[off + 0];
539
540 BasicLibrary lib = Instance.getInstance().getLibrary();
541 lib.delete(luid);
542
543 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
544 }
545
546 @Override
547 protected List<MetaData> metas(WLoginResult login) throws IOException {
548 BasicLibrary lib = Instance.getInstance().getLibrary();
549 List<MetaData> metas = new ArrayList<MetaData>();
550 for (MetaData meta : lib.getList().getMetas()) {
551 if (isAllowed(meta, login)) {
552 metas.add(meta);
553 }
554 }
555
556 return metas;
557 }
558
559 // NULL if not whitelist OK or if not found
560 @Override
561 protected Story story(String luid, WLoginResult login) throws IOException {
562 synchronized (storyCache) {
563 if (storyCache.containsKey(luid)) {
564 Story story = storyCache.get(luid);
565 if (!isAllowed(story.getMeta(), login))
566 return null;
567
568 return story;
569 }
570 }
571
572 Story story = null;
573 MetaData meta = meta(luid, login);
574 if (meta != null) {
575 BasicLibrary lib = Instance.getInstance().getLibrary();
576 story = lib.getStory(luid, null);
577 long size = sizeOf(story);
578
579 synchronized (storyCache) {
580 // Could have been added by another request
581 if (!storyCache.containsKey(luid)) {
582 while (!storyCacheOrder.isEmpty()
583 && storyCacheSize + size > maxStoryCacheSize) {
584 String oldestLuid = storyCacheOrder.removeFirst();
585 Story oldestStory = storyCache.remove(oldestLuid);
586 maxStoryCacheSize -= sizeOf(oldestStory);
587 }
588
589 storyCacheOrder.add(luid);
590 storyCache.put(luid, story);
591 }
592 }
593 }
594
595 return story;
596 }
597
598 private MetaData meta(String luid, WLoginResult login) throws IOException {
599 BasicLibrary lib = Instance.getInstance().getLibrary();
600 MetaData meta = lib.getInfo(luid);
601 if (!isAllowed(meta, login))
602 return null;
603
604 return meta;
605 }
606
607 private Image storyCover(String luid, WLoginResult login)
608 throws IOException {
609 MetaData meta = meta(luid, login);
610 if (meta != null) {
611 BasicLibrary lib = Instance.getInstance().getLibrary();
612 return lib.getCover(meta.getLuid());
613 }
614
615 return null;
616 }
617
618 private Image authorCover(String author, WLoginResult login)
619 throws IOException {
620 Image img = null;
621
622 List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
623 author, null);
624 if (metas.size() > 0) {
625 BasicLibrary lib = Instance.getInstance().getLibrary();
626 img = lib.getCustomAuthorCover(author);
627 if (img == null)
628 img = lib.getCover(metas.get(0).getLuid());
629 }
630
631 return img;
632
633 }
634
635 private void authorCover(String author, WLoginResult login, String luid)
636 throws IOException {
637 if (meta(luid, login) != null) {
638 List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
639 author, null);
640 if (metas.size() > 0) {
641 BasicLibrary lib = Instance.getInstance().getLibrary();
642 lib.setAuthorCover(author, luid);
643 }
644 }
645 }
646
647 private Image sourceCover(String source, WLoginResult login)
648 throws IOException {
649 Image img = null;
650
651 List<MetaData> metas = new MetaResultList(metas(login)).filter(source,
652 null, null);
653 if (metas.size() > 0) {
654 BasicLibrary lib = Instance.getInstance().getLibrary();
655 img = lib.getCustomSourceCover(source);
656 if (img == null)
657 img = lib.getCover(metas.get(0).getLuid());
658 }
659
660 return img;
661 }
662
663 private void sourceCover(String source, WLoginResult login, String luid)
664 throws IOException {
665 if (meta(luid, login) != null) {
666 List<MetaData> metas = new MetaResultList(metas(login))
667 .filter(source, null, null);
668 if (metas.size() > 0) {
669 BasicLibrary lib = Instance.getInstance().getLibrary();
670 lib.setSourceCover(source, luid);
671 }
672 }
673 }
674
675 private boolean isAllowed(MetaData meta, WLoginResult login) {
676 MetaResultList one = new MetaResultList(Arrays.asList(meta));
677 if (login.isWl() && !whitelist.isEmpty()) {
678 if (one.filter(whitelist, null, null).isEmpty()) {
679 return false;
680 }
681 }
682 if (login.isBl() && !blacklist.isEmpty()) {
683 if (!one.filter(blacklist, null, null).isEmpty()) {
684 return false;
685 }
686 }
687
688 return true;
689 }
690
691 private long sizeOf(Story story) {
692 long size = 0;
693 for (Chapter chap : story) {
694 for (Paragraph para : chap) {
695 if (para.getType() == ParagraphType.IMAGE) {
696 size += para.getContentImage().getSize();
697 } else {
698 size += para.getContent().length();
699 }
700 }
701 }
702
703 return size;
704 }
705
706 public static void main(String[] args) throws IOException {
707 Instance.init();
708 WebLibraryServer web = new WebLibraryServer(false);
709 web.run();
710 }
711 }