fix STOP server
[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 new Thread(new Runnable() {
135 @Override
136 public void run() {
137 try {
138 Thread.sleep(1500);
139 } catch (InterruptedException e) {
140 }
141
142 Instance.getInstance().getTraceHandler()
143 .trace("Exit timeout: force-quit");
144 System.exit(0);
145 }
146 }, "Exit program after timeout of 1500 ms").start();
147
148 return NanoHTTPD.newFixedLengthResponse(Status.OK,
149 NanoHTTPD.MIME_PLAINTEXT, "Exited");
150 }
151
152 @Override
153 protected WLoginResult login(boolean badLogin, boolean badCookie) {
154 return new WLoginResult(false, false);
155 }
156
157 @Override
158 protected WLoginResult login(String who, String cookie) {
159 List<String> subkeys = Instance.getInstance().getConfig()
160 .getList(Config.SERVER_ALLOWED_SUBKEYS);
161 String realKey = Instance.getInstance().getConfig()
162 .getString(Config.SERVER_KEY);
163
164 return new WLoginResult(cookie, who, realKey, subkeys);
165 }
166
167 // allow rw/wl
168 @Override
169 protected WLoginResult login(String who, String key, String subkey) {
170 String realKey = Instance.getInstance().getConfig()
171 .getString(Config.SERVER_KEY, "");
172
173 // I don't like NULLs...
174 key = key == null ? "" : key;
175 subkey = subkey == null ? "" : subkey;
176
177 if (!realKey.equals(key)) {
178 return new WLoginResult(true, false);
179 }
180
181 // defaults are true (as previous versions without the feature)
182 boolean rw = true;
183 boolean wl = true;
184 boolean bl = true;
185
186 rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
187 rw);
188
189 List<String> allowed = Instance.getInstance().getConfig().getList(
190 Config.SERVER_ALLOWED_SUBKEYS, new ArrayList<String>());
191
192 if (!allowed.isEmpty()) {
193 if (!allowed.contains(subkey)) {
194 return new WLoginResult(true, false);
195 }
196
197 if ((subkey + "|").contains("|rw|")) {
198 rw = true;
199 }
200 if ((subkey + "|").contains("|wl|")) {
201 wl = false; // |wl| = bypass whitelist
202 }
203 if ((subkey + "|").contains("|bl|")) {
204 bl = false; // |bl| = bypass blacklist
205 }
206 }
207
208 return new WLoginResult(who, key, subkey, rw, wl, bl);
209 }
210
211 @Override
212 protected Response getList(String uri, WLoginResult login)
213 throws IOException {
214 if (WebLibraryUrls.LIST_URL_METADATA.equals(uri)) {
215 List<JSONObject> jsons = new ArrayList<JSONObject>();
216 for (MetaData meta : metas(login)) {
217 jsons.add(JsonIO.toJson(meta));
218 }
219
220 return newInputStreamResponse("application/json",
221 new ByteArrayInputStream(
222 new JSONArray(jsons).toString().getBytes()));
223 }
224
225 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
226 NanoHTTPD.MIME_PLAINTEXT, null);
227 }
228
229 // /story/luid/chapter/para <-- text/image
230 // /story/luid/cover <-- image
231 // /story/luid/metadata <-- json
232 // /story/luid/json <-- json, whole chapter (no images)
233 @Override
234 protected Response getStoryPart(String uri, WLoginResult login) {
235 String[] uriParts = uri.split("/");
236 int off = 2;
237
238 if (uriParts.length < off + 2) {
239 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
240 NanoHTTPD.MIME_PLAINTEXT, null);
241 }
242
243 String luid = uriParts[off + 0];
244 String chapterStr = uriParts[off + 1];
245 String imageStr = uriParts.length < off + 3 ? null : uriParts[off + 2];
246
247 // 1-based (0 = desc)
248 int chapter = 0;
249 if (chapterStr != null && !"cover".equals(chapterStr)
250 && !"metadata".equals(chapterStr)
251 && !"json".equals(chapterStr)) {
252 try {
253 chapter = Integer.parseInt(chapterStr);
254 if (chapter < 0) {
255 throw new NumberFormatException();
256 }
257 } catch (NumberFormatException e) {
258 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
259 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
260 }
261 }
262
263 // 1-based
264 int paragraph = 1;
265 if (imageStr != null) {
266 try {
267 paragraph = Integer.parseInt(imageStr);
268 if (paragraph < 0) {
269 throw new NumberFormatException();
270 }
271 } catch (NumberFormatException e) {
272 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
273 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
274 }
275 }
276
277 String mimeType = NanoHTTPD.MIME_PLAINTEXT;
278 InputStream in = null;
279 try {
280 if ("cover".equals(chapterStr)) {
281 Image img = storyCover(luid, login);
282 if (img != null) {
283 in = img.newInputStream();
284 }
285 // TODO: get correct image type
286 mimeType = "image/png";
287 } else if ("metadata".equals(chapterStr)) {
288 MetaData meta = meta(luid, login);
289 JSONObject json = JsonIO.toJson(meta);
290 mimeType = "application/json";
291 in = new ByteArrayInputStream(json.toString().getBytes());
292 } else if ("json".equals(chapterStr)) {
293 Story story = story(luid, login);
294 JSONObject json = JsonIO.toJson(story);
295 mimeType = "application/json";
296 in = new ByteArrayInputStream(json.toString().getBytes());
297 } else {
298 Story story = story(luid, login);
299 if (story != null) {
300 if (chapter == 0) {
301 StringBuilder builder = new StringBuilder();
302 for (Paragraph p : story.getMeta().getResume()) {
303 if (builder.length() == 0) {
304 builder.append("\n");
305 }
306 builder.append(p.getContent());
307 }
308
309 in = new ByteArrayInputStream(
310 builder.toString().getBytes("utf-8"));
311 } else {
312 Paragraph para = story.getChapters().get(chapter - 1)
313 .getParagraphs().get(paragraph - 1);
314 Image img = para.getContentImage();
315 if (para.getType() == ParagraphType.IMAGE) {
316 // TODO: get correct image type
317 mimeType = "image/png";
318 in = img.newInputStream();
319 } else {
320 in = new ByteArrayInputStream(
321 para.getContent().getBytes("utf-8"));
322 }
323 }
324 }
325 }
326 } catch (IndexOutOfBoundsException e) {
327 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
328 NanoHTTPD.MIME_PLAINTEXT,
329 "Chapter or paragraph does not exist");
330 } catch (IOException e) {
331 Instance.getInstance().getTraceHandler()
332 .error(new IOException("Cannot get image: " + uri, e));
333 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
334 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
335 }
336
337 return newInputStreamResponse(mimeType, in);
338 }
339
340 // /story/luid/source
341 // /story/luid/title
342 // /story/luid/author
343 @Override
344 protected Response setStoryPart(String uri, String value,
345 WLoginResult login) throws IOException {
346 String[] uriParts = uri.split("/");
347 int off = 2; // "" and "story"
348
349 if (uriParts.length < off + 2) {
350 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
351 NanoHTTPD.MIME_PLAINTEXT, "Invalid story part request");
352 }
353
354 if (!login.isRw()) {
355 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
356 NanoHTTPD.MIME_PLAINTEXT, "SET story part not allowed");
357 }
358
359 if (exiting) {
360 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
361 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
362 }
363
364 String luid = uriParts[off + 0];
365 String type = uriParts[off + 1];
366
367 if (!Arrays.asList("source", "title", "author").contains(type)) {
368 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
369 NanoHTTPD.MIME_PLAINTEXT,
370 "Invalid SET story part: " + type);
371 }
372
373 if (meta(luid, login) != null) {
374 BasicLibrary lib = Instance.getInstance().getLibrary();
375 if ("source".equals(type)) {
376 lib.changeSource(luid, value, null);
377 } else if ("title".equals(type)) {
378 lib.changeTitle(luid, value, null);
379 } else if ("author".equals(type)) {
380 lib.changeAuthor(luid, value, null);
381 }
382 }
383
384 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
385 }
386
387 @Override
388 protected Response getCover(String uri, WLoginResult login)
389 throws IOException {
390 String[] uriParts = uri.split("/");
391 int off = 2; // "" and "cover"
392
393 if (uriParts.length < off + 2) {
394 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
395 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
396 }
397
398 String type = uriParts[off + 0];
399 String id = uriParts[off + 1];
400
401 InputStream in = null;
402
403 if ("story".equals(type)) {
404 Image img = storyCover(id, login);
405 if (img != null) {
406 in = img.newInputStream();
407 }
408 } else if ("source".equals(type)) {
409 Image img = sourceCover(id, login);
410 if (img != null) {
411 in = img.newInputStream();
412 }
413 } else if ("author".equals(type)) {
414 Image img = authorCover(id, login);
415 if (img != null) {
416 in = img.newInputStream();
417 }
418 } else {
419 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
420 NanoHTTPD.MIME_PLAINTEXT,
421 "Invalid GET cover type: " + type);
422 }
423
424 // TODO: get correct image type
425 return newInputStreamResponse("image/png", in);
426 }
427
428 @Override
429 protected Response setCover(String uri, String luid, WLoginResult login)
430 throws IOException {
431 String[] uriParts = uri.split("/");
432 int off = 2; // "" and "cover"
433
434 if (uriParts.length < off + 2) {
435 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
436 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
437 }
438
439 if (!login.isRw()) {
440 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
441 NanoHTTPD.MIME_PLAINTEXT, "Cover request not allowed");
442 }
443
444 if (exiting) {
445 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
446 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
447 }
448
449 String type = uriParts[off + 0];
450 String id = uriParts[off + 1];
451
452 if ("source".equals(type)) {
453 sourceCover(id, login, luid);
454 } else if ("author".equals(type)) {
455 authorCover(id, login, luid);
456 } else {
457 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
458 NanoHTTPD.MIME_PLAINTEXT,
459 "Invalid SET cover type: " + type);
460 }
461
462 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
463 }
464
465 @Override
466 protected Response imprt(String uri, String urlStr, WLoginResult login)
467 throws IOException {
468 final BasicLibrary lib = Instance.getInstance().getLibrary();
469
470 if (!login.isRw()) {
471 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
472 NanoHTTPD.MIME_PLAINTEXT, "Import not allowed");
473 }
474
475 if (exiting) {
476 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
477 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
478 }
479
480 final URL url = new URL(urlStr);
481 final Progress pg = new Progress();
482 final String luid = lib.getNextId();
483
484 synchronized (imprts) {
485 imprts.put(luid, pg);
486 }
487
488 new Thread(new Runnable() {
489 @Override
490 public void run() {
491 try {
492 lib.imprt(url, luid, pg);
493 } catch (IOException e) {
494 Instance.getInstance().getTraceHandler().error(e);
495 } finally {
496 synchronized (imprts) {
497 imprts.remove(luid);
498 }
499 }
500 }
501 }, "Import story: " + urlStr).start();
502
503 return NanoHTTPD.newFixedLengthResponse(Status.OK,
504 NanoHTTPD.MIME_PLAINTEXT, luid);
505 }
506
507 @Override
508 protected Response imprtProgress(String uri, WLoginResult login) {
509 String[] uriParts = uri.split("/");
510 int off = 2; // "" and "import"
511
512 if (uriParts.length < off + 1) {
513 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
514 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
515 }
516
517 String luid = uriParts[off + 0];
518
519 Progress pg = null;
520 synchronized (imprts) {
521 pg = imprts.get(luid);
522 }
523 if (pg != null) {
524 return NanoHTTPD.newFixedLengthResponse(Status.OK,
525 "application/json", JsonIO.toJson(pg).toString());
526 }
527
528 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
529 }
530
531 @Override
532 protected Response delete(String uri, WLoginResult login)
533 throws IOException {
534 String[] uriParts = uri.split("/");
535 int off = 2; // "" and "delete"
536
537 if (uriParts.length < off + 1) {
538 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
539 NanoHTTPD.MIME_PLAINTEXT, "Invalid delete request");
540 }
541
542 if (!login.isRw()) {
543 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
544 NanoHTTPD.MIME_PLAINTEXT, "Delete not allowed");
545 }
546
547 if (exiting) {
548 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
549 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
550 }
551
552 String luid = uriParts[off + 0];
553
554 BasicLibrary lib = Instance.getInstance().getLibrary();
555 lib.delete(luid);
556
557 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
558 }
559
560 @Override
561 protected List<MetaData> metas(WLoginResult login) throws IOException {
562 BasicLibrary lib = Instance.getInstance().getLibrary();
563 List<MetaData> metas = new ArrayList<MetaData>();
564 for (MetaData meta : lib.getList().getMetas()) {
565 if (isAllowed(meta, login)) {
566 metas.add(meta);
567 }
568 }
569
570 return metas;
571 }
572
573 // NULL if not whitelist OK or if not found
574 @Override
575 protected Story story(String luid, WLoginResult login) throws IOException {
576 synchronized (storyCache) {
577 if (storyCache.containsKey(luid)) {
578 Story story = storyCache.get(luid);
579 if (!isAllowed(story.getMeta(), login))
580 return null;
581
582 return story;
583 }
584 }
585
586 Story story = null;
587 MetaData meta = meta(luid, login);
588 if (meta != null) {
589 BasicLibrary lib = Instance.getInstance().getLibrary();
590 story = lib.getStory(luid, null);
591 long size = sizeOf(story);
592
593 synchronized (storyCache) {
594 // Could have been added by another request
595 if (!storyCache.containsKey(luid)) {
596 while (!storyCacheOrder.isEmpty()
597 && storyCacheSize + size > maxStoryCacheSize) {
598 String oldestLuid = storyCacheOrder.removeFirst();
599 Story oldestStory = storyCache.remove(oldestLuid);
600 maxStoryCacheSize -= sizeOf(oldestStory);
601 }
602
603 storyCacheOrder.add(luid);
604 storyCache.put(luid, story);
605 }
606 }
607 }
608
609 return story;
610 }
611
612 private MetaData meta(String luid, WLoginResult login) throws IOException {
613 BasicLibrary lib = Instance.getInstance().getLibrary();
614 MetaData meta = lib.getInfo(luid);
615 if (!isAllowed(meta, login))
616 return null;
617
618 return meta;
619 }
620
621 private Image storyCover(String luid, WLoginResult login)
622 throws IOException {
623 MetaData meta = meta(luid, login);
624 if (meta != null) {
625 BasicLibrary lib = Instance.getInstance().getLibrary();
626 return lib.getCover(meta.getLuid());
627 }
628
629 return null;
630 }
631
632 private Image authorCover(String author, WLoginResult login)
633 throws IOException {
634 Image img = null;
635
636 List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
637 author, null);
638 if (metas.size() > 0) {
639 BasicLibrary lib = Instance.getInstance().getLibrary();
640 img = lib.getCustomAuthorCover(author);
641 if (img == null)
642 img = lib.getCover(metas.get(0).getLuid());
643 }
644
645 return img;
646
647 }
648
649 private void authorCover(String author, WLoginResult login, String luid)
650 throws IOException {
651 if (meta(luid, login) != null) {
652 List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
653 author, null);
654 if (metas.size() > 0) {
655 BasicLibrary lib = Instance.getInstance().getLibrary();
656 lib.setAuthorCover(author, luid);
657 }
658 }
659 }
660
661 private Image sourceCover(String source, WLoginResult login)
662 throws IOException {
663 Image img = null;
664
665 List<MetaData> metas = new MetaResultList(metas(login)).filter(source,
666 null, null);
667 if (metas.size() > 0) {
668 BasicLibrary lib = Instance.getInstance().getLibrary();
669 img = lib.getCustomSourceCover(source);
670 if (img == null)
671 img = lib.getCover(metas.get(0).getLuid());
672 }
673
674 return img;
675 }
676
677 private void sourceCover(String source, WLoginResult login, String luid)
678 throws IOException {
679 if (meta(luid, login) != null) {
680 List<MetaData> metas = new MetaResultList(metas(login))
681 .filter(source, null, null);
682 if (metas.size() > 0) {
683 BasicLibrary lib = Instance.getInstance().getLibrary();
684 lib.setSourceCover(source, luid);
685 }
686 }
687 }
688
689 private boolean isAllowed(MetaData meta, WLoginResult login) {
690 MetaResultList one = new MetaResultList(Arrays.asList(meta));
691 if (login.isWl() && !whitelist.isEmpty()) {
692 if (one.filter(whitelist, null, null).isEmpty()) {
693 return false;
694 }
695 }
696 if (login.isBl() && !blacklist.isEmpty()) {
697 if (!one.filter(blacklist, null, null).isEmpty()) {
698 return false;
699 }
700 }
701
702 return true;
703 }
704
705 private long sizeOf(Story story) {
706 long size = 0;
707 for (Chapter chap : story) {
708 for (Paragraph para : chap) {
709 if (para.getType() == ParagraphType.IMAGE) {
710 size += para.getContentImage().getSize();
711 } else {
712 size += para.getContent().length();
713 }
714 }
715 }
716
717 return size;
718 }
719
720 public static void main(String[] args) throws IOException {
721 Instance.init();
722 WebLibraryServer web = new WebLibraryServer(false);
723 web.run();
724 }
725 }