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